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