1use crate::components::ui::{Icon, IconName};
11use leptos::ev::{FocusEvent, KeyboardEvent, MouseEvent};
12use leptos::prelude::*;
13
14pub const INPUT_CLASS: &str = "field w-full";
16
17pub const TEXTAREA_CLASS: &str = "field field-textarea";
19
20static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
22
23pub const FORM_SELECT_COMPACT_CLASS: &str = "field field-sm w-auto";
25
26const FILTER_THRESHOLD: usize = 8;
28
29fn use_instance_prefix() -> Signal<Option<usize>> {
35 let prefix = RwSignal::new(None::<usize>);
36 Effect::new(move |_| {
37 prefix.set(Some(
38 FORM_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
39 ));
40 });
41 prefix.into()
42}
43
44#[allow(dead_code)] fn should_flip(
49 trigger_top: f64,
50 trigger_bottom: f64,
51 viewport_height: f64,
52 panel_height: f64,
53) -> bool {
54 const MARGIN: f64 = 14.0;
56 let below = viewport_height - trigger_bottom;
57 let above = trigger_top;
58 below < panel_height + MARGIN && above > below
59}
60
61fn wrap_index(cur: usize, delta: i32, len: usize) -> usize {
63 if len == 0 {
64 return 0;
65 }
66 (cur as i32 + delta).rem_euclid(len as i32) as usize
67}
68
69fn step_active(navigable: &[usize], cur: usize, delta: i32) -> Option<usize> {
71 if navigable.is_empty() {
72 return None;
73 }
74 match navigable.iter().position(|&i| i == cur) {
75 Some(pos) => Some(navigable[wrap_index(pos, delta, navigable.len())]),
76 None if delta < 0 => navigable.last().copied(),
77 None => navigable.first().copied(),
78 }
79}
80
81fn matches_query(label: &str, query: &str) -> bool {
83 let query = query.trim();
84 query.is_empty() || label.to_lowercase().contains(&query.to_lowercase())
85}
86
87#[cfg(target_arch = "wasm32")]
89pub(crate) fn measure_flip(trigger_id: &str, option_count: usize) -> bool {
90 const ROW_HEIGHT: f64 = 36.0;
92 const PANEL_CHROME: f64 = 8.0;
94 const PANEL_MAX: f64 = 288.0;
96
97 let Some(window) = web_sys::window() else {
98 return false;
99 };
100 let Some(document) = window.document() else {
101 return false;
102 };
103 let Some(el) = document.get_element_by_id(trigger_id) else {
104 return false;
105 };
106 let rect = el.get_bounding_client_rect();
107 let viewport = window
108 .inner_height()
109 .ok()
110 .and_then(|v| v.as_f64())
111 .unwrap_or(800.0);
112 let panel_height = ((option_count as f64) * ROW_HEIGHT + PANEL_CHROME).min(PANEL_MAX);
113 should_flip(rect.top(), rect.bottom(), viewport, panel_height)
114}
115
116#[cfg(target_arch = "wasm32")]
118fn scroll_option_into_view(element_id: &str) {
119 let Some(document) = web_sys::window().and_then(|w| w.document()) else {
120 return;
121 };
122 let Some(el) = document.get_element_by_id(element_id) else {
123 return;
124 };
125 let opts = web_sys::ScrollIntoViewOptions::new();
126 opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
127 el.scroll_into_view_with_scroll_into_view_options(&opts);
128}
129
130#[cfg(target_arch = "wasm32")]
132fn scroll_into_center(element_id: &str) {
133 let Some(document) = web_sys::window().and_then(|w| w.document()) else {
134 return;
135 };
136 let Some(el) = document.get_element_by_id(element_id) else {
137 return;
138 };
139 let opts = web_sys::ScrollIntoViewOptions::new();
140 opts.set_block(web_sys::ScrollLogicalPosition::Center);
141 el.scroll_into_view_with_scroll_into_view_options(&opts);
142}
143
144#[cfg(target_arch = "wasm32")]
146fn focus_by_id(element_id: &str) {
147 use wasm_bindgen::JsCast;
148 if let Some(el) = web_sys::window()
149 .and_then(|w| w.document())
150 .and_then(|d| d.get_element_by_id(element_id))
151 .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
152 {
153 let _ = el.focus();
154 }
155}
156
157#[component]
184pub fn FormSelect<T, L>(
185 #[prop(optional, into)] id: MaybeProp<String>,
186 #[prop(into)] value: Signal<T>,
187 #[prop(into)] options: Signal<Vec<(T, L)>>,
188 #[prop(into)] onchange: Callback<T>,
189 #[prop(optional, into)] disabled_options: Signal<Vec<(T, &'static str)>>,
190 #[prop(optional)] trigger_class: Option<&'static str>,
191 #[prop(optional)] aria_label: Option<&'static str>,
192) -> impl IntoView
193where
194 T: Clone + PartialEq + Send + Sync + 'static,
195 L: AsRef<str> + Send + Sync + 'static,
196{
197 let trigger_cls = format!("select-trigger {}", trigger_class.unwrap_or("field"));
198 let prefix = use_instance_prefix();
199 let trigger_id = Signal::derive(move || {
200 id.get()
201 .or_else(|| prefix.get().map(|n| format!("form-select-{n}")))
202 });
203 let filter_id = move || prefix.get().map(|n| format!("form-select-{n}-filter"));
204 let option_id = move |i: usize| {
205 prefix
206 .get()
207 .map(|n| format!("form-select-{n}-opt-{i}"))
208 .unwrap_or_default()
209 };
210
211 let selected = Memo::new(move |_| {
213 let current = value.get();
214 options.with(|o| o.iter().position(|(v, _)| *v == current).unwrap_or(0))
215 });
216 let len = Memo::new(move |_| options.with(Vec::len));
217 let filterable = Memo::new(move |_| len.get() > FILTER_THRESHOLD);
218
219 let open = RwSignal::new(false);
220 let active = RwSignal::new(selected.get_untracked());
221 let query = RwSignal::new(String::new());
222 let flip_up = RwSignal::new(false);
223
224 let reasons = Memo::new(move |_| {
226 options.with(|o| {
227 disabled_options.with(|d| {
228 o.iter()
229 .map(|(v, _)| d.iter().find(|(x, _)| x == v).map(|(_, reason)| *reason))
230 .collect::<Vec<Option<&'static str>>>()
231 })
232 })
233 });
234 let visible = Memo::new(move |_| {
235 let q = query.get();
236 options.with(|o| {
237 (0..o.len())
238 .filter(|&i| matches_query(o[i].1.as_ref(), &q))
239 .collect::<Vec<usize>>()
240 })
241 });
242 let navigable = Memo::new(move |_| {
243 reasons.with(|r| {
244 visible.with(|v| {
245 v.iter()
246 .copied()
247 .filter(|&i| r[i].is_none())
248 .collect::<Vec<usize>>()
249 })
250 })
251 });
252
253 Effect::new(move |_| {
255 if open.get() {
256 let _idx = active.get();
257 #[cfg(target_arch = "wasm32")]
258 {
259 scroll_option_into_view(&option_id(_idx));
260 if filterable.get_untracked() {
261 if let Some(filter) = filter_id() {
262 focus_by_id(&filter);
263 }
264 }
265 }
266 }
267 });
268
269 let focus_trigger = move || {
270 #[cfg(target_arch = "wasm32")]
271 if let Some(trigger) = trigger_id.get_untracked() {
272 focus_by_id(&trigger);
273 }
274 };
275
276 let open_panel = move || {
277 #[cfg(target_arch = "wasm32")]
278 if let Some(trigger) = trigger_id.get_untracked() {
279 flip_up.set(measure_flip(
280 &trigger,
281 len.get_untracked().min(FILTER_THRESHOLD + 1),
282 ));
283 }
284 query.set(String::new());
285 active.set(selected.get_untracked());
286 open.set(true);
287 };
288
289 let pick = move |i: usize| {
291 open.set(false);
292 if let Some(v) = options.with_untracked(|o| o.get(i).map(|(v, _)| v.clone())) {
293 onchange.run(v);
294 }
295 focus_trigger();
296 };
297
298 let handle_open_key = move |e: &KeyboardEvent| {
300 let key = e.key();
301 match key.as_str() {
302 "ArrowDown" | "ArrowUp" => {
303 e.prevent_default();
304 let delta = if key == "ArrowDown" { 1 } else { -1 };
305 if let Some(next) =
306 navigable.with_untracked(|n| step_active(n, active.get_untracked(), delta))
307 {
308 active.set(next);
309 }
310 }
311 "Home" => {
312 e.prevent_default();
313 if let Some(first) = navigable.with_untracked(|n| n.first().copied()) {
314 active.set(first);
315 }
316 }
317 "End" => {
318 e.prevent_default();
319 if let Some(last) = navigable.with_untracked(|n| n.last().copied()) {
320 active.set(last);
321 }
322 }
323 "Enter" | " " if key == "Enter" || !filterable.get_untracked() => {
324 e.prevent_default();
325 let current = active.get_untracked();
326 if navigable.with_untracked(|n| n.contains(¤t)) {
327 pick(current);
328 }
329 }
330 "Escape" => {
331 e.prevent_default();
332 open.set(false);
333 focus_trigger();
334 }
335 "Tab" => open.set(false),
336 _ => {}
337 }
338 };
339
340 view! {
341 <div class="relative">
342 <button
343 id=move || trigger_id.get()
344 type="button"
345 class=trigger_cls
346 aria-haspopup="listbox"
347 aria-expanded=move || open.get().to_string()
348 aria-activedescendant=move || {
349 (open.get() && !filterable.get()).then(|| option_id(active.get()))
350 }
351 aria-label=aria_label
352 on:click=move |_| {
353 if !open.get_untracked() {
356 open_panel();
357 }
358 }
359 on:keydown=move |e: KeyboardEvent| {
360 if open.get_untracked() {
361 handle_open_key(&e);
362 return;
363 }
364 if matches!(e.key().as_str(), "ArrowDown" | "ArrowUp" | "Enter" | " ") {
365 e.prevent_default();
366 open_panel();
367 }
368 }
369 >
370 <span class="select-value">
371 {move || {
372 options.with(|o| {
373 o.get(selected.get())
374 .map(|(_, label)| label.as_ref().to_string())
375 .unwrap_or_default()
376 })
377 }}
378 </span>
379 <Icon name=IconName::ChevronDown size=15 class="select-chevron"/>
380 </button>
381
382 {move || {
383 open.get()
384 .then(|| {
385 view! {
386 <div class="fixed inset-0 z-40" on:click=move |_| open.set(false)></div>
388 <div
389 class="listbox listbox-centered listbox-split"
390 data-placement=move || if flip_up.get() { "top" } else { "bottom" }
391 >
392 {move || {
393 filterable
394 .get()
395 .then(|| {
396 view! {
397 <div class="listbox-filter">
398 <input
399 id=filter_id
400 class="field field-sm w-full"
401 type="text"
402 placeholder="筛选"
403 autocomplete="off"
404 prop:value=move || query.get()
405 aria-label="筛选选项"
406 aria-activedescendant=move || option_id(active.get())
407 on:input=move |e| {
408 let text = event_target_value(&e);
409 let first = options
410 .with_untracked(|o| {
411 reasons
412 .with_untracked(|r| {
413 o.iter()
414 .zip(r)
415 .position(|((_, label), reason)| {
416 reason.is_none() && matches_query(label.as_ref(), &text)
417 })
418 })
419 });
420 if let Some(first) = first {
421 active.set(first);
422 }
423 query.set(text);
424 }
425 on:keydown=move |e: KeyboardEvent| handle_open_key(&e)
426 />
427 </div>
428 }
429 })
430 }}
431 <ul
432 class="listbox-scroll"
433 role="listbox"
434 aria-labelledby=move || trigger_id.get()
435 >
436 {move || {
437 visible
438 .with(Vec::is_empty)
439 .then(|| {
440 view! {
441 <li class="listbox-empty" role="presentation">
442 "没有匹配的选项"
443 </li>
444 }
445 })
446 }}
447 {move || {
448 visible
449 .get()
450 .into_iter()
451 .map(|i| {
452 let label = options
453 .with(|o| {
454 o.get(i)
455 .map(|(_, l)| l.as_ref().to_string())
456 .unwrap_or_default()
457 });
458 let reason = reasons.with(|r| r.get(i).copied().flatten());
459 view! {
460 <SelectOption
461 id=option_id(i)
462 label=label
463 reason=reason
464 selected=Signal::derive(move || selected.get() == i)
465 active=Signal::derive(move || active.get() == i)
466 on_hover=move |_| {
467 if reason.is_none() {
468 active.set(i);
469 }
470 }
471 on_pick=move |_| pick(i)
472 />
473 }
474 })
475 .collect_view()
476 }}
477 </ul>
478 </div>
479 }
480 })
481 }}
482 </div>
483 }
484}
485
486#[component]
488fn SelectOption(
489 id: String,
490 label: String,
491 reason: Option<&'static str>,
492 #[prop(into)] selected: Signal<bool>,
493 #[prop(into)] active: Signal<bool>,
494 #[prop(into)] on_hover: Callback<()>,
495 #[prop(into)] on_pick: Callback<()>,
496) -> impl IntoView {
497 view! {
498 <li
499 id=id
500 class="listbox-option"
501 role="option"
502 aria-selected=move || selected.get().to_string()
503 aria-disabled=reason.is_some().then_some("true")
504 data-active=move || active.get().to_string()
505 on:mousedown=move |e: MouseEvent| e.prevent_default()
507 on:mouseenter=move |_| on_hover.run(())
508 on:click=move |_| {
509 if reason.is_none() {
510 on_pick.run(());
511 }
512 }
513 >
514 <span class="min-w-0 flex-1">
515 <span class="block truncate">{label}</span>
516 {reason.map(|reason| view! { <span class="listbox-option-hint">{reason}</span> })}
517 </span>
518 {move || {
519 selected.get().then(|| view! { <Icon name=IconName::Check size=14 class="listbox-check"/> })
520 }}
521 </li>
522 }
523}
524
525fn parse_hhmm(value: &str) -> (u8, u8) {
528 let mut parts = value.split(':');
529 let hour = parts
530 .next()
531 .and_then(|s| s.parse::<u8>().ok())
532 .filter(|h| *h < 24);
533 let minute = parts
534 .next()
535 .and_then(|s| s.parse::<u8>().ok())
536 .filter(|m| *m < 60);
537 match (hour, minute) {
538 (Some(h), Some(m)) => (h, m),
539 _ => (0, 0),
540 }
541}
542
543#[component]
554pub fn TimePicker(
555 #[prop(optional, into)] id: MaybeProp<String>,
556 #[prop(into)] value: Signal<String>,
557 #[prop(into)] onchange: Callback<String>,
558) -> impl IntoView {
559 let prefix = use_instance_prefix();
560 let trigger_id = Signal::derive(move || {
561 id.get()
562 .or_else(|| prefix.get().map(|n| format!("time-picker-{n}")))
563 });
564 let parsed = Memo::new(move |_| parse_hhmm(&value.get()));
565 let hour = Memo::new(move |_| parsed.get().0);
566 let minute = Memo::new(move |_| parsed.get().1);
567 let text = move || format!("{:02}:{:02}", hour.get(), minute.get());
568 let open = RwSignal::new(false);
569
570 Effect::new(move |_| {
572 if open.get() {
573 #[cfg(target_arch = "wasm32")]
574 if let Some(n) = prefix.get_untracked() {
575 scroll_into_center(&format!("time-picker-{n}-h"));
576 scroll_into_center(&format!("time-picker-{n}-m"));
577 }
578 }
579 });
580
581 let utc = move || {
583 cfg!(target_arch = "wasm32").then(|| crate::utils::time::local_hhmm_to_utc(&text()))
584 };
585
586 let close = move || {
587 open.set(false);
588 #[cfg(target_arch = "wasm32")]
589 if let Some(trigger) = trigger_id.get_untracked() {
590 focus_by_id(&trigger);
591 }
592 };
593
594 let hour_id = move |h: u8| {
595 (h == hour.get())
596 .then(|| prefix.get().map(|n| format!("time-picker-{n}-h")))
597 .flatten()
598 };
599 let minute_id = move |m: u8| {
600 (m == minute.get())
601 .then(|| prefix.get().map(|n| format!("time-picker-{n}-m")))
602 .flatten()
603 };
604
605 view! {
606 <div class="relative inline-block">
607 <button
608 id=move || trigger_id.get()
609 type="button"
610 class="select-trigger field w-40"
611 aria-haspopup="dialog"
612 aria-expanded=move || open.get().to_string()
613 on:click=move |_| open.set(true)
614 >
615 <span class="font-mono tabular-nums">{text}</span>
616 <span class="text-xs text-fg-3">"本地时间"</span>
617 </button>
618 {move || {
619 open.get()
620 .then(|| {
621 view! {
622 <div class="fixed inset-0 z-40" on:click=move |_| open.set(false)></div>
623 <div
624 class="listbox time-panel"
625 data-placement="bottom"
626 role="dialog"
627 aria-label="选择时间"
628 on:keydown=move |e: KeyboardEvent| {
629 if e.key() == "Escape" {
630 e.prevent_default();
631 close();
632 }
633 }
634 >
635 <div class="time-columns">
636 <div
637 class="time-column"
638 role="listbox"
639 aria-label="小时"
640 on:keydown=move |e: KeyboardEvent| {
641 let delta = match e.key().as_str() {
642 "ArrowDown" => 1,
643 "ArrowUp" => -1,
644 _ => return,
645 };
646 e.prevent_default();
647 let h = wrap_index(hour.get_untracked() as usize, delta, 24);
648 onchange.run(format!("{h:02}:{:02}", minute.get_untracked()));
649 }
650 >
651 {(0..24u8)
652 .map(|h| {
653 view! {
654 <button
655 id=move || hour_id(h)
656 class="time-cell"
657 type="button"
658 role="option"
659 aria-selected=move || (h == hour.get()).to_string()
660 tabindex=move || if h == hour.get() { "0" } else { "-1" }
661 on:click=move |_| {
662 onchange.run(format!("{h:02}:{:02}", minute.get_untracked()))
663 }
664 >
665 {format!("{h:02}")}
666 </button>
667 }
668 })
669 .collect_view()}
670 </div>
671 <div
672 class="time-column"
673 role="listbox"
674 aria-label="分钟"
675 on:keydown=move |e: KeyboardEvent| {
676 let delta = match e.key().as_str() {
677 "ArrowDown" => 1,
678 "ArrowUp" => -1,
679 _ => return,
680 };
681 e.prevent_default();
682 let m = wrap_index(minute.get_untracked() as usize, delta, 60);
683 onchange.run(format!("{:02}:{m:02}", hour.get_untracked()));
684 }
685 >
686 {(0..60u8)
687 .map(|m| {
688 view! {
689 <button
690 id=move || minute_id(m)
691 class="time-cell"
692 type="button"
693 role="option"
694 aria-selected=move || (m == minute.get()).to_string()
695 tabindex=move || if m == minute.get() { "0" } else { "-1" }
696 on:click=move |_| {
697 onchange.run(format!("{:02}:{m:02}", hour.get_untracked()))
698 }
699 >
700 {format!("{m:02}")}
701 </button>
702 }
703 })
704 .collect_view()}
705 </div>
706 </div>
707 <div class="time-footer">
708 <span>{move || utc().map(|utc| format!("UTC {utc}"))}</span>
709 <button
710 class="btn btn-xs btn-primary"
711 type="button"
712 on:click=move |_| close()
713 >
714 "确定"
715 </button>
716 </div>
717 </div>
718 }
719 })
720 }}
721 </div>
722 }
723}
724
725#[component]
748pub fn FormInput(
749 #[prop(optional, into)] id: MaybeProp<String>,
750 #[prop(default = "text")] r#type: &'static str,
751 #[prop(optional)] placeholder: Option<&'static str>,
752 #[prop(into)] value: Signal<String>,
753 #[prop(optional, into)] disabled: Signal<bool>,
754 #[prop(optional, into)] invalid: Signal<bool>,
755 #[prop(optional)] required: bool,
756 #[prop(optional)] readonly: bool,
757 #[prop(optional)] autofocus: bool,
758 #[prop(optional)] maxlength: Option<i32>,
759 #[prop(optional)] autocomplete: Option<&'static str>,
760 #[prop(optional)] min: Option<&'static str>,
761 #[prop(optional)] step: Option<&'static str>,
762 #[prop(optional)] aria_describedby: Option<&'static str>,
763 #[prop(optional, into)] oninput: Option<Callback<String>>,
764 #[prop(optional, into)] onkeydown: Option<Callback<KeyboardEvent>>,
765 #[prop(optional, into)] onfocus: Option<Callback<FocusEvent>>,
766 #[prop(optional, into)] onblur: Option<Callback<FocusEvent>>,
767 #[prop(optional)] class: Option<&'static str>,
768 #[prop(optional)] mono: bool,
769 #[prop(optional)] inputmode: Option<&'static str>,
770 #[prop(optional)] title: Option<&'static str>,
771 #[prop(optional)] aria_label: Option<&'static str>,
772) -> impl IntoView {
773 let base = class.unwrap_or(INPUT_CLASS);
774 let mono_class = if mono { " font-mono" } else { "" };
775 let class = move || {
776 let disabled_class = if disabled.get() {
777 " opacity-60 cursor-not-allowed"
778 } else {
779 ""
780 };
781 format!("{base}{mono_class}{disabled_class}")
782 };
783 view! {
784 <input
785 id=move || id.get()
786 class=class
787 type=r#type
788 placeholder=placeholder
789 value=value.get_untracked()
790 prop:value=move || value.get()
791 disabled=move || disabled.get()
792 required=required
793 readonly=readonly
794 autofocus=autofocus
795 maxlength=maxlength.map(|n| n.to_string())
796 autocomplete=autocomplete
797 min=min
798 step=step
799 aria-describedby=aria_describedby
800 aria-invalid=move || invalid.get().then_some("true")
801 inputmode=inputmode
802 title=title
803 aria-label=aria_label
804 on:input=move |e| {
805 if let Some(handler) = oninput {
806 handler.run(event_target_value(&e));
807 }
808 }
809 on:keydown=move |e| {
810 if let Some(handler) = onkeydown {
811 handler.run(e);
812 }
813 }
814 on:focus=move |e| {
815 if let Some(handler) = onfocus {
816 handler.run(e);
817 }
818 }
819 on:blur=move |e| {
820 if let Some(handler) = onblur {
821 handler.run(e);
822 }
823 }
824 />
825 }
826}
827
828#[component]
831pub fn FormTextarea(
832 #[prop(optional, into)] id: MaybeProp<String>,
833 #[prop(into)] value: Signal<String>,
834 #[prop(optional)] placeholder: Option<&'static str>,
835 #[prop(default = 3)] rows: u32,
836 #[prop(optional)] maxlength: Option<i32>,
837 #[prop(optional, into)] invalid: Signal<bool>,
838 #[prop(into)] oninput: Callback<String>,
839 #[prop(optional)] class: Option<&'static str>,
840) -> impl IntoView {
841 view! {
842 <textarea
843 id=move || id.get()
844 class=class.unwrap_or(TEXTAREA_CLASS)
845 rows=rows
846 maxlength=maxlength.map(|n| n.to_string())
847 placeholder=placeholder
848 prop:value=move || value.get()
849 aria-invalid=move || invalid.get().then_some("true")
850 on:input=move |e| oninput.run(event_target_value(&e))
851 >
852 {value.get_untracked()}
853 </textarea>
854 }
855}
856
857#[component]
872pub fn NumberInput(
873 #[prop(optional, into)] id: MaybeProp<String>,
874 #[prop(into)] value: Signal<i64>,
875 min: i64,
876 max: i64,
877 #[prop(default = 1)] step: i64,
878 #[prop(optional)] unit: Option<&'static str>,
879 #[prop(optional, into)] hint: MaybeProp<String>,
880 #[prop(optional)] aria_label: Option<&'static str>,
881 #[prop(into)] onchange: Callback<i64>,
882) -> impl IntoView {
883 let prefix = use_instance_prefix();
884 let input_id = Signal::derive(move || {
885 id.get()
886 .or_else(|| prefix.get().map(|n| format!("number-input-{n}")))
887 });
888 let help_id = Signal::derive(move || input_id.get().map(|id| format!("{id}-help")));
889 let draft = RwSignal::new(value.get_untracked().to_string());
890 Effect::new(move |previous: Option<i64>| {
892 let current = value.get();
893 if previous.is_some_and(|p| p != current) {
894 draft.set(current.to_string());
895 }
896 current
897 });
898
899 let parsed = Memo::new(move |_| parse_in_range(&draft.get(), min, max));
900 let invalid = move || parsed.get().is_none();
901 let current = move || parsed.get().unwrap_or_else(|| value.get());
902 let help = move || {
903 if invalid() {
904 Some(format!("超出范围:请输入 {min} 到 {max} 之间的整数。"))
905 } else {
906 hint.get()
907 }
908 };
909 let at_min = move || current() <= min;
910 let at_max = move || current() >= max;
911 let set = move |next: i64| {
912 let next = next.clamp(min, max);
913 draft.set(next.to_string());
914 onchange.run(next);
915 };
916
917 view! {
918 <div>
919 <div class="field number-field">
920 <button
921 class="btn btn-sm btn-icon btn-ghost"
922 type="button"
923 aria-label="减少"
924 disabled=at_min
925 on:click=move |_| set(current() - step)
926 >
927 <Icon name=IconName::Minus size=14/>
928 </button>
929 <input
930 id=move || input_id.get()
931 type="text"
932 inputmode="numeric"
933 value=draft.get_untracked()
934 prop:value=move || draft.get()
935 aria-label=aria_label
936 aria-invalid=move || invalid().then_some("true")
937 aria-describedby=move || help().and_then(|_| help_id.get())
938 on:input=move |e| {
939 let text: String = event_target_value(&e)
940 .chars()
941 .filter(|c| c.is_ascii_digit() || *c == '-')
942 .collect();
943 if let Some(v) = parse_in_range(&text, min, max) {
944 onchange.run(v);
945 }
946 draft.set(text);
947 }
948 on:keydown=move |e: KeyboardEvent| {
949 let delta = match e.key().as_str() {
950 "ArrowUp" => step,
951 "ArrowDown" => -step,
952 _ => return,
953 };
954 e.prevent_default();
955 set(current() + delta);
956 }
957 />
958 {unit.map(|unit| view! { <span class="number-unit">{unit}</span> })}
959 <button
960 class="btn btn-sm btn-icon btn-ghost"
961 type="button"
962 aria-label="增加"
963 disabled=at_max
964 on:click=move |_| set(current() + step)
965 >
966 <Icon name=IconName::Plus size=14/>
967 </button>
968 </div>
969 {move || {
970 help()
971 .map(|help| {
972 view! {
973 <p
974 id=move || help_id.get()
975 class="field-hint"
976 data-tone=move || if invalid() { "danger" } else { "" }
977 >
978 {help}
979 </p>
980 }
981 })
982 }}
983 </div>
984 }
985}
986
987fn parse_in_range(text: &str, min: i64, max: i64) -> Option<i64> {
989 text.trim()
990 .parse::<i64>()
991 .ok()
992 .filter(|v| (min..=max).contains(v))
993}
994
995#[component]
1001pub fn FormLabel(
1002 #[prop(into)] label: Signal<String>,
1003 #[prop(optional, into)] html_for: MaybeProp<String>,
1004) -> impl IntoView {
1005 view! {
1006 <label class="field-label" for=move || html_for.get()>
1007 {move || label.get()}
1008 </label>
1009 }
1010}
1011
1012#[component]
1018pub fn AlertBox(
1019 #[prop(into)] message: Signal<String>,
1020 #[prop(into)] variant: Signal<&'static str>,
1021) -> impl IntoView {
1022 let tone = move || match variant.get() {
1023 "error" => "danger",
1024 "success" => "success",
1025 _ => "",
1026 };
1027 let role = move || match variant.get() {
1028 "error" => "alert",
1029 _ => "status",
1030 };
1031 let icon = move || match variant.get() {
1032 "error" => IconName::CircleAlert,
1033 "success" => IconName::CircleCheck,
1034 _ => IconName::Info,
1035 };
1036 view! {
1037 <div class="alert mb-4" data-tone=tone role=role>
1038 {move || view! { <Icon name=icon() size=16/> }}
1039 <span>{move || message.get()}</span>
1040 </div>
1041 }
1042}
1043
1044#[component]
1055pub fn ToggleSwitch(
1056 #[prop(into)] checked: Signal<bool>,
1057 #[prop(into)] ontoggle: Callback<()>,
1058 #[prop(optional, into)] aria_label: MaybeProp<String>,
1059 #[prop(optional, into)] disabled: Signal<bool>,
1060) -> impl IntoView {
1061 view! {
1062 <button
1063 type="button"
1064 role="switch"
1065 class="switch"
1066 aria-label=move || aria_label.get()
1067 aria-checked=move || checked.get().to_string()
1068 disabled=move || disabled.get()
1069 on:click=move |_| ontoggle.run(())
1070 >
1071 <span class="switch-thumb"></span>
1072 </button>
1073 }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079 use leptos::reactive::owner::Owner;
1080
1081 #[test]
1082 fn parse_hhmm_valid_values() {
1083 assert_eq!(parse_hhmm("00:00"), (0, 0));
1084 assert_eq!(parse_hhmm("04:30"), (4, 30));
1085 assert_eq!(parse_hhmm("23:59"), (23, 59));
1086 }
1087
1088 #[test]
1089 fn parse_hhmm_invalid_falls_back_to_zero() {
1090 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)); }
1096
1097 #[test]
1098 fn wrap_index_cycles_both_directions() {
1099 assert_eq!(wrap_index(0, 1, 3), 1);
1100 assert_eq!(wrap_index(2, 1, 3), 0); assert_eq!(wrap_index(0, -1, 3), 2); assert_eq!(wrap_index(1, -1, 3), 0);
1103 }
1104
1105 #[test]
1106 fn wrap_index_empty_is_zero() {
1107 assert_eq!(wrap_index(5, 1, 0), 0); }
1109
1110 #[test]
1111 fn should_flip_only_when_below_insufficient_and_above_wider() {
1112 assert!(!should_flip(100.0, 140.0, 800.0, 200.0));
1114 assert!(should_flip(600.0, 640.0, 800.0, 200.0));
1116 assert!(!should_flip(30.0, 70.0, 260.0, 200.0));
1118 assert!(!should_flip(300.0, 340.0, 554.0, 200.0));
1120 }
1121
1122 #[test]
1123 fn step_active_skips_filtered_and_disabled_options() {
1124 let navigable = [1, 3, 4];
1126 assert_eq!(step_active(&navigable, 1, 1), Some(3));
1127 assert_eq!(step_active(&navigable, 4, 1), Some(1)); assert_eq!(step_active(&navigable, 1, -1), Some(4)); assert_eq!(step_active(&navigable, 2, 1), Some(1));
1131 assert_eq!(step_active(&navigable, 2, -1), Some(4));
1132 assert_eq!(step_active(&[], 0, 1), None);
1133 }
1134
1135 #[test]
1136 fn filter_matches_case_insensitive_substrings() {
1137 assert!(matches_query("PostgreSQL", "sql"));
1138 assert!(matches_query("最新上传", "上传"));
1139 assert!(matches_query("任意", " "));
1140 assert!(!matches_query("按文件名", "体积"));
1141 }
1142
1143 #[test]
1144 fn number_input_accepts_only_values_in_range() {
1145 assert_eq!(parse_in_range("30", 1, 90), Some(30));
1146 assert_eq!(parse_in_range(" 90 ", 1, 90), Some(90));
1147 assert_eq!(parse_in_range("0", 1, 90), None);
1148 assert_eq!(parse_in_range("120", 1, 90), None);
1149 assert_eq!(parse_in_range("", 1, 90), None);
1150 assert_eq!(parse_in_range("-", 1, 90), None);
1151 }
1152
1153 fn render<V: IntoView + 'static>(build: impl FnOnce() -> V) -> String {
1156 Owner::new().with(|| build().to_html())
1157 }
1158
1159 #[test]
1160 fn form_input_renders_attributes_and_state_classes() {
1161 let html = render(|| {
1162 view! {
1163 <FormInput
1164 id="login-name"
1165 r#type="email"
1166 placeholder="邮箱"
1167 value="a@b.c"
1168 invalid=true
1169 disabled=true
1170 mono=true
1171 maxlength=64
1172 autocomplete="off"
1173 oninput=move |_: String| {}
1174 />
1175 }
1176 });
1177 assert!(html.contains("id=\"login-name\""), "{html}");
1178 assert!(html.contains("type=\"email\""), "{html}");
1179 assert!(html.contains("placeholder=\"邮箱\""), "{html}");
1180 assert!(html.contains("value=\"a@b.c\""), "{html}");
1181 assert!(html.contains("aria-invalid=\"true\""), "{html}");
1182 assert!(html.contains("maxlength=\"64\""), "{html}");
1183 assert!(
1184 html.contains("field w-full font-mono opacity-60 cursor-not-allowed"),
1185 "{html}"
1186 );
1187 assert!(html.contains("disabled"), "{html}");
1188 }
1189
1190 #[test]
1191 fn form_input_omits_unset_optional_attributes() {
1192 let html = render(|| {
1193 let value = RwSignal::new(String::new());
1194 view! { <FormInput value=value/> }
1195 });
1196 assert!(!html.contains("id="), "{html}");
1197 assert!(!html.contains("aria-invalid"), "{html}");
1198 assert!(!html.contains("inputmode"), "{html}");
1199 assert!(!html.contains("title"), "{html}");
1200 assert!(html.contains("class=\"field w-full\""), "{html}");
1201 }
1202
1203 #[test]
1204 fn form_textarea_renders_initial_text_and_rows() {
1205 let html = render(|| {
1206 view! {
1207 <FormTextarea id="bio" value="第一行" rows=5 invalid=true oninput=move |_: String| {}/>
1208 }
1209 });
1210 assert!(html.contains("<textarea"), "{html}");
1211 assert!(html.contains("rows=\"5\""), "{html}");
1212 assert!(html.contains("field field-textarea"), "{html}");
1213 assert!(html.contains("aria-invalid=\"true\""), "{html}");
1214 assert!(html.contains("第一行</textarea>"), "{html}");
1215 }
1216
1217 #[test]
1218 fn alert_box_maps_variant_to_tone_role_and_icon() {
1219 let error = render(|| view! { <AlertBox message="出错了" variant="error"/> });
1220 assert!(error.contains("data-tone=\"danger\""), "{error}");
1221 assert!(error.contains("role=\"alert\""), "{error}");
1222 assert!(error.contains("lucide-circle-alert"), "{error}");
1223 assert!(error.contains("出错了"), "{error}");
1224
1225 let success = render(|| view! { <AlertBox message="成功" variant="success"/> });
1226 assert!(success.contains("data-tone=\"success\""), "{success}");
1227 assert!(success.contains("role=\"status\""), "{success}");
1228
1229 let info = render(|| view! { <AlertBox message="提示" variant="info"/> });
1230 assert!(info.contains("data-tone=\"\""), "{info}");
1231 assert!(info.contains("lucide-info"), "{info}");
1232 }
1233
1234 #[test]
1235 fn toggle_switch_exposes_switch_semantics() {
1236 let html = render(|| {
1237 let checked = RwSignal::new(true);
1238 view! { <ToggleSwitch checked=checked aria_label="启用缓存" ontoggle=move |_| {}/> }
1239 });
1240 assert!(html.contains("role=\"switch\""), "{html}");
1241 assert!(html.contains("aria-checked=\"true\""), "{html}");
1242 assert!(html.contains("aria-label=\"启用缓存\""), "{html}");
1243 assert!(html.contains("switch-thumb"), "{html}");
1244 }
1245
1246 #[test]
1247 fn form_label_associates_with_input() {
1248 let html = render(|| view! { <FormLabel label="用户名" html_for="login-name"/> });
1249 assert!(html.contains("class=\"field-label\""), "{html}");
1250 assert!(html.contains("for=\"login-name\""), "{html}");
1251 assert!(html.contains("用户名"), "{html}");
1252 }
1253
1254 #[test]
1255 fn form_select_closed_renders_only_trigger_with_selected_label() {
1256 let html = render(|| {
1257 let sort = RwSignal::new(2_u8);
1258 view! {
1259 <FormSelect
1260 id="sort"
1261 value=sort
1262 options=vec![(1_u8, "最新上传"), (2, "文件最大")]
1263 trigger_class=FORM_SELECT_COMPACT_CLASS
1264 aria_label="排序"
1265 onchange=move |v: u8| sort.set(v)
1266 />
1267 }
1268 });
1269 assert!(html.contains("id=\"sort\""), "{html}");
1270 assert!(html.contains("aria-haspopup=\"listbox\""), "{html}");
1271 assert!(html.contains("aria-expanded=\"false\""), "{html}");
1272 assert!(html.contains("aria-label=\"排序\""), "{html}");
1273 assert!(
1274 html.contains("select-trigger field field-sm w-auto"),
1275 "{html}"
1276 );
1277 assert!(html.contains("文件最大"), "{html}");
1278 assert!(!html.contains("role=\"listbox\""), "{html}");
1279 assert!(!html.contains("最新上传"), "{html}");
1280 }
1281
1282 #[test]
1283 fn form_select_falls_back_to_first_option_for_unknown_value() {
1284 let html = render(|| {
1285 view! {
1286 <FormSelect
1287 value=Signal::derive(|| "zzz".to_string())
1288 options=vec![("a".to_string(), "甲".to_string()), ("b".to_string(), "乙".to_string())]
1289 onchange=move |_: String| {}
1290 />
1291 }
1292 });
1293 assert!(html.contains("甲"), "{html}");
1294 }
1295
1296 #[test]
1298 fn form_select_accepts_common_call_shapes() {
1299 const REFRESH: [(u32, &str); 2] = [(0, "关闭"), (5000, "5 秒")];
1300 let html = render(|| {
1301 let refresh = RwSignal::new(5000_u32);
1302 let tag = RwSignal::new("rust".to_string());
1303 let published = RwSignal::new(None::<bool>);
1304 let tags = RwSignal::new(vec![
1305 ("all".to_string(), "全部".to_string()),
1306 ("rust".to_string(), "Rust".to_string()),
1307 ]);
1308 view! {
1309 <FormSelect value=refresh options=REFRESH.to_vec() onchange=move |v| refresh.set(v)/>
1311 <FormSelect
1313 value=move || tag.get()
1314 options=move || tags.get()
1315 onchange=move |v: String| tag.set(v)
1316 />
1317 <FormSelect
1319 value=published
1320 options=vec![(None, "公开状态"), (Some(true), "已公开"), (Some(false), "私密")]
1321 onchange={move |v: Option<bool>| published.set(v)}
1322 />
1323 <FormSelect
1325 value=3_u32
1326 options=vec![(1, "1"), (3, "3")]
1327 disabled_options=vec![(1, "需要先重建索引")]
1328 onchange=move |_: u32| {}
1329 />
1330 }
1331 });
1332 assert!(html.contains("5 秒"), "{html}");
1333 assert!(html.contains("Rust"), "{html}");
1334 assert!(html.contains("公开状态"), "{html}");
1335 }
1336
1337 #[test]
1338 fn time_picker_closed_shows_normalized_time() {
1339 let html = render(|| {
1340 view! {
1341 <TimePicker id="backup-time" value="4:05" onchange=move |_: String| {}/>
1342 }
1343 });
1344 assert!(html.contains("04:05"), "{html}");
1346 assert!(html.contains("本地时间"), "{html}");
1347 assert!(html.contains("aria-haspopup=\"dialog\""), "{html}");
1348 assert!(!html.contains("time-panel"), "{html}");
1349 }
1350
1351 #[test]
1352 fn number_input_renders_unit_and_range_error() {
1353 let ok = render(|| {
1354 view! {
1355 <NumberInput id="days" value=30_i64 min=1 max=90 unit="天" hint="保留天数" onchange=move |_: i64| {}/>
1356 }
1357 });
1358 assert!(ok.contains("number-unit"), "{ok}");
1359 assert!(ok.contains("天"), "{ok}");
1360 assert!(ok.contains("保留天数"), "{ok}");
1361 assert!(ok.contains("aria-describedby=\"days-help\""), "{ok}");
1362 assert!(ok.contains("data-tone=\"\""), "{ok}");
1363
1364 let bad = render(|| {
1365 view! {
1366 <NumberInput id="days" value=120_i64 min=1 max=90 onchange=move |_: i64| {}/>
1367 }
1368 });
1369 assert!(bad.contains("data-tone=\"danger\""), "{bad}");
1370 assert!(bad.contains("aria-invalid=\"true\""), "{bad}");
1371 assert!(
1372 bad.contains("超出范围:请输入 1 到 90 之间的整数。"),
1373 "{bad}"
1374 );
1375 }
1376}