1use dioxus::prelude::*;
6
7pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
9
10pub const INPUT_INLINE_CLASS: &str = "flex-1 min-w-0 px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
13
14pub const INPUT_SEARCH_CLASS: &str = "w-full pr-10 px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200 ygg-search-clear";
19
20pub const BUTTON_PRIMARY_CLASS: &str = "w-full py-2.5 px-4 bg-paper-accent text-white font-medium rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer";
22
23static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
25
26pub const FORM_SELECT_COMPACT_CLASS: &str = "inline-flex w-auto cursor-pointer select-none text-left text-sm pl-3 pr-8 py-1 border border-paper-border rounded-lg bg-paper-theme text-paper-primary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
29
30#[allow(dead_code)] fn should_flip(
35 trigger_top: f64,
36 trigger_bottom: f64,
37 viewport_height: f64,
38 panel_height: f64,
39) -> bool {
40 const MARGIN: f64 = 14.0;
42 let below = viewport_height - trigger_bottom;
43 let above = trigger_top;
44 below < panel_height + MARGIN && above > below
45}
46
47fn wrap_index(cur: usize, delta: i32, len: usize) -> usize {
49 if len == 0 {
50 return 0;
51 }
52 (cur as i32 + delta).rem_euclid(len as i32) as usize
53}
54
55#[cfg(target_arch = "wasm32")]
57pub(crate) fn measure_flip(trigger_id: &str, option_count: usize) -> bool {
58 const ROW_HEIGHT: f64 = 44.0;
60 const PANEL_CHROME: f64 = 14.0;
62 const PANEL_MAX: f64 = 254.0;
64
65 let Some(window) = web_sys::window() else {
66 return false;
67 };
68 let Some(document) = window.document() else {
69 return false;
70 };
71 let Some(el) = document.get_element_by_id(trigger_id) else {
72 return false;
73 };
74 let rect = el.get_bounding_client_rect();
75 let viewport = window
76 .inner_height()
77 .ok()
78 .and_then(|v| v.as_f64())
79 .unwrap_or(800.0);
80 let panel_height = ((option_count as f64) * ROW_HEIGHT + PANEL_CHROME).min(PANEL_MAX);
81 should_flip(rect.top(), rect.bottom(), viewport, panel_height)
82}
83
84#[cfg(target_arch = "wasm32")]
86fn scroll_option_into_view(element_id: &str) {
87 let Some(document) = web_sys::window().and_then(|w| w.document()) else {
88 return;
89 };
90 let Some(el) = document.get_element_by_id(element_id) else {
91 return;
92 };
93 let opts = web_sys::ScrollIntoViewOptions::new();
94 opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
95 el.scroll_into_view_with_scroll_into_view_options(&opts);
96}
97
98#[component]
118pub fn FormSelect<T: Clone + PartialEq + 'static>(
119 id: Option<String>,
120 value: T,
121 options: Vec<(T, &'static str)>,
122 onchange: EventHandler<T>,
123 #[props(default)]
126 trigger_class: Option<&'static str>,
127 #[props(default)]
129 aria_label: Option<&'static str>,
130) -> Element {
131 const TRIGGER_CLASS: &str = "w-full block cursor-pointer truncate select-none text-left pl-4 pr-10 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
140 const PANEL_CLASS: &str = "absolute left-1/2 z-50 w-max min-w-full max-w-[calc(100vw_-_2rem)] [transform:translateX(-50%)] max-h-60 overflow-y-auto rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] p-1.5 shadow-lg animate-select-enter";
141
142 let trigger_cls = trigger_class.unwrap_or(TRIGGER_CLASS);
143
144 let id_prefix = use_hook(|| FORM_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
145
146 let selected = options.iter().position(|(v, _)| *v == value).unwrap_or(0);
148 let selected_label = options.get(selected).map(|(_, l)| *l).unwrap_or_default();
149 let len = options.len();
150
151 let mut open = use_signal(|| false);
152 let mut active = use_signal(|| selected);
153 #[allow(unused_mut)]
155 let mut flip_up = use_signal(|| false);
156
157 let options_for_keys = options.clone();
159
160 let trigger_id = id.unwrap_or_else(|| format!("form-select-{id_prefix}"));
162 #[cfg(target_arch = "wasm32")]
164 let trigger_id_click = trigger_id.clone();
165 #[cfg(target_arch = "wasm32")]
166 let trigger_id_keys = trigger_id.clone();
167
168 use_effect(move || {
170 if open() {
171 #[cfg(target_arch = "wasm32")]
172 {
173 let idx = active();
174 scroll_option_into_view(&format!("form-select-{id_prefix}-opt-{idx}"));
175 }
176 }
177 });
178
179 let active_idx = active();
181 let rows: Vec<(usize, T, &'static str, &'static str, &'static str)> = options
182 .iter()
183 .enumerate()
184 .map(|(i, (v, l))| {
185 let highlight = if i == active_idx {
186 "bg-[var(--color-paper-accent-soft)]"
187 } else {
188 ""
189 };
190 let text = if i == selected {
191 "text-paper-accent"
192 } else {
193 "text-[var(--color-paper-primary)]"
194 };
195 (i, v.clone(), *l, highlight, text)
196 })
197 .collect();
198
199 let chevron_rotate = if open() { "rotate-180" } else { "" };
200 let placement_cls = if flip_up() {
201 "bottom-full mb-1.5 origin-bottom"
202 } else {
203 "top-full mt-1.5 origin-top"
204 };
205 let active_descendant = open().then(|| {
206 let idx = active();
207 format!("form-select-{id_prefix}-opt-{idx}")
208 });
209
210 rsx! {
211 div { class: "relative",
212 button {
213 id: "{trigger_id}",
214 r#type: "button",
215 class: "{trigger_cls}",
216 aria_haspopup: "listbox",
217 aria_expanded: "{open()}",
218 aria_activedescendant: active_descendant,
219 aria_label,
220 onclick: move |_| {
221 if !open() {
224 #[cfg(target_arch = "wasm32")]
225 flip_up.set(measure_flip(&trigger_id_click, len));
226 active.set(selected);
227 open.set(true);
228 }
229 },
230 onkeydown: move |e| {
231 let key = e.key();
232 let is_space = matches!(&key, Key::Character(s) if s == " ");
233 if !open() {
234 if key == Key::ArrowDown || key == Key::ArrowUp || key == Key::Enter
235 || is_space
236 {
237 e.prevent_default();
238 #[cfg(target_arch = "wasm32")]
239 flip_up.set(measure_flip(&trigger_id_keys, len));
240 active.set(selected);
241 open.set(true);
242 }
243 return;
244 }
245 if key == Key::ArrowDown {
246 e.prevent_default();
247 active.set(wrap_index(active(), 1, len));
248 } else if key == Key::ArrowUp {
249 e.prevent_default();
250 active.set(wrap_index(active(), -1, len));
251 } else if key == Key::Home { e.prevent_default();
253 active.set(0);
254 } else if key == Key::End {
255 e.prevent_default();
256 active.set(len.saturating_sub(1));
257 } else if key == Key::Enter || is_space {
258 e.prevent_default();
259 if let Some((v, _)) = options_for_keys.get(active()) {
260 onchange.call(v.clone());
261 }
262 open.set(false);
263 } else if key == Key::Escape {
264 e.prevent_default();
265 open.set(false);
266 } else if key == Key::Tab {
267 open.set(false);
268 }
269 },
270 "{selected_label}"
271 svg {
273 class: "pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-paper-secondary transition-transform duration-200 {chevron_rotate}",
274 view_box: "0 0 24 24",
275 fill: "none",
276 stroke: "currentColor",
277 stroke_width: "2",
278 path {
279 stroke_linecap: "round",
280 stroke_linejoin: "round",
281 d: "M6 9l6 6 6-6",
282 }
283 }
284 }
285
286 if open() {
287 div {
289 class: "fixed inset-0 z-40",
290 onclick: move |_| open.set(false),
291 }
292 ul {
293 class: "{PANEL_CLASS} {placement_cls}",
294 role: "listbox",
295 aria_labelledby: "{trigger_id}",
296 for (i, opt_value, opt_label, highlight_cls, text_cls) in rows {
297 li {
298 id: "form-select-{id_prefix}-opt-{i}",
299 class: "flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl cursor-pointer select-none transition-colors hover:bg-[var(--color-paper-accent-soft)] {text_cls} {highlight_cls}",
300 role: "option",
301 aria_selected: "{i == selected}",
302 onmousedown: move |e| e.prevent_default(),
304 onclick: move |_| {
305 onchange.call(opt_value.clone());
306 open.set(false);
307 },
308 onmouseenter: move |_| active.set(i),
309 span { class: "truncate", "{opt_label}" }
310 if i == selected {
311 svg {
312 class: "w-4 h-4 flex-shrink-0",
313 view_box: "0 0 24 24",
314 fill: "none",
315 stroke: "currentColor",
316 stroke_width: "2",
317 path {
318 stroke_linecap: "round",
319 stroke_linejoin: "round",
320 d: "M20 6L9 17l-5-5",
321 }
322 }
323 }
324 }
325 }
326 }
327 }
328 }
329 }
330}
331
332const HOUR_LABELS: [&str; 24] = [
335 "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15",
336 "16", "17", "18", "19", "20", "21", "22", "23",
337];
338const MINUTE_LABELS: [&str; 60] = [
339 "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15",
340 "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31",
341 "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47",
342 "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
343];
344
345fn parse_hhmm(value: &str) -> (u8, u8) {
348 let mut parts = value.split(':');
349 let hour = parts
350 .next()
351 .and_then(|s| s.parse::<u8>().ok())
352 .filter(|h| *h < 24);
353 let minute = parts
354 .next()
355 .and_then(|s| s.parse::<u8>().ok())
356 .filter(|m| *m < 60);
357 match (hour, minute) {
358 (Some(h), Some(m)) => (h, m),
359 _ => (0, 0),
360 }
361}
362
363#[component]
378pub fn TimePicker(id: Option<String>, value: String, onchange: EventHandler<String>) -> Element {
379 const TIME_TRIGGER_CLASS: &str = "inline-flex w-auto cursor-pointer select-none text-sm tabular-nums pl-2.5 pr-8 py-2 rounded-md bg-transparent text-paper-primary focus:outline-none focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
383
384 let (hour, minute) = parse_hhmm(&value);
385 let hour_options: Vec<(u8, &'static str)> = HOUR_LABELS
386 .iter()
387 .enumerate()
388 .map(|(i, l)| (i as u8, *l))
389 .collect();
390 let minute_options: Vec<(u8, &'static str)> = MINUTE_LABELS
391 .iter()
392 .enumerate()
393 .map(|(i, l)| (i as u8, *l))
394 .collect();
395
396 rsx! {
397 div { class: "inline-flex items-center gap-0.5 rounded-lg border border-paper-border bg-paper-entry",
398 FormSelect {
399 id,
400 aria_label: "小时",
401 value: hour,
402 options: hour_options,
403 trigger_class: TIME_TRIGGER_CLASS,
404 onchange: move |h: u8| onchange.call(format!("{h:02}:{minute:02}")),
405 }
406 span { class: "text-sm text-paper-tertiary select-none", ":" }
407 FormSelect {
408 aria_label: "分钟",
409 value: minute,
410 options: minute_options,
411 trigger_class: TIME_TRIGGER_CLASS,
412 onchange: move |m: u8| onchange.call(format!("{hour:02}:{m:02}")),
413 }
414 }
415 }
416}
417
418#[component]
432pub fn FormInput(
433 id: Option<String>,
434 r#type: &'static str,
435 placeholder: &'static str,
436 value: String,
437 #[props(default)] disabled: bool,
438 oninput: EventHandler<String>,
439 #[props(default)] onkeydown: Option<EventHandler<KeyboardEvent>>,
440 #[props(default)] class: Option<&'static str>,
441 #[props(default)] mono: bool,
442) -> Element {
443 let base = class.unwrap_or(INPUT_CLASS);
444 let mono_class = if mono { " font-mono" } else { "" };
445 let disabled_class = if disabled {
446 " opacity-60 cursor-not-allowed"
447 } else {
448 ""
449 };
450 rsx! {
451 input {
452 id: id.unwrap_or_default(),
453 class: "{base}{mono_class}{disabled_class}",
454 r#type: "{r#type}",
455 placeholder: "{placeholder}",
456 value: "{value}",
457 disabled,
458 oninput: move |e| oninput.call(e.value()),
459 onkeydown: move |e| {
460 if let Some(ref handler) = onkeydown {
461 handler.call(e);
462 }
463 },
464 }
465 }
466}
467
468#[component]
474pub fn FormLabel(label: String, html_for: Option<String>) -> Element {
475 rsx! {
476 label {
477 class: "block text-sm font-medium text-paper-secondary mb-1",
478 r#for: html_for.unwrap_or_default(),
479 "{label}"
480 }
481 }
482}
483
484#[component]
490pub fn AlertBox(message: String, variant: &'static str) -> Element {
491 let (bg_class, text_class) = match variant {
492 "error" => (
493 "bg-red-100 dark:bg-red-900/30",
494 "text-red-700 dark:text-red-300",
495 ),
496 "success" => (
497 "bg-green-100 dark:bg-green-900/30",
498 "text-green-700 dark:text-green-300",
499 ),
500 _ => ("bg-paper-code-bg", "text-paper-secondary"),
501 };
502 rsx! {
503 div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", "{message}" }
504 }
505}
506
507#[component]
517pub fn ToggleSwitch(checked: bool, ontoggle: Callback<()>) -> Element {
518 let track_class = if checked {
519 "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-accent cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40"
520 } else {
521 "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-tertiary cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40"
522 };
523 let thumb_class = if checked {
524 "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200 translate-x-5"
525 } else {
526 "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200"
527 };
528 rsx! {
529 button {
530 role: "switch",
531 aria_checked: "{checked}",
532 class: "{track_class}",
533 onclick: move |_| ontoggle.call(()),
534 span { class: "{thumb_class}" }
535 }
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::{parse_hhmm, should_flip, wrap_index};
542
543 #[test]
544 fn parse_hhmm_valid_values() {
545 assert_eq!(parse_hhmm("00:00"), (0, 0));
546 assert_eq!(parse_hhmm("04:30"), (4, 30));
547 assert_eq!(parse_hhmm("23:59"), (23, 59));
548 }
549
550 #[test]
551 fn parse_hhmm_invalid_falls_back_to_zero() {
552 assert_eq!(parse_hhmm(""), (0, 0)); assert_eq!(parse_hhmm("12"), (0, 0)); assert_eq!(parse_hhmm("24:00"), (0, 0)); assert_eq!(parse_hhmm("12:60"), (0, 0)); assert_eq!(parse_hhmm("ab:cd"), (0, 0)); }
558
559 #[test]
560 fn wrap_index_cycles_both_directions() {
561 assert_eq!(wrap_index(0, 1, 3), 1);
562 assert_eq!(wrap_index(2, 1, 3), 0); assert_eq!(wrap_index(0, -1, 3), 2); assert_eq!(wrap_index(1, -1, 3), 0);
565 }
566
567 #[test]
568 fn wrap_index_empty_is_zero() {
569 assert_eq!(wrap_index(5, 1, 0), 0); }
571
572 #[test]
573 fn should_flip_only_when_below_insufficient_and_above_wider() {
574 assert!(!should_flip(100.0, 140.0, 800.0, 200.0));
576 assert!(should_flip(600.0, 640.0, 800.0, 200.0));
578 assert!(!should_flip(30.0, 70.0, 260.0, 200.0));
580 assert!(!should_flip(300.0, 340.0, 554.0, 200.0));
582 }
583}