Skip to main content

yggdrasil/components/
forms.rs

1//! 表单控件组件
2//!
3//! 提供登录、注册、评论等页面共享的输入框、下拉、时间、数字、开关与提示条。
4//! 外观定义在 styles/components/ui.css(`.field`、`.listbox`、`.switch`、`.alert`)。
5//!
6//! 约定(Leptos):随状态变化的 props 一律是 `Signal<T>` / `MaybeProp<T>`,可直接传
7//! 常量、信号或 `move ||` 闭包;受控值写作 `value=信号` + `oninput`/`onchange` 回调
8//! (回调里由调用方写回信号)。`id` 等可选字符串是 `MaybeProp<String>`,`id="x"` 即可。
9
10use crate::components::ui::{Icon, IconName};
11use leptos::ev::{FocusEvent, KeyboardEvent, MouseEvent};
12use leptos::prelude::*;
13
14/// 输入框基础类:浅底、发丝线环,聚焦陶土双环,`aria-invalid` 时红环。
15pub const INPUT_CLASS: &str = "field w-full";
16
17/// 多行输入框基础类:随内容长高(`.field-textarea`)。
18pub const TEXTAREA_CLASS: &str = "field field-textarea";
19
20/// FormSelect / TimePicker / NumberInput 实例序号计数器(跨泛型单例化全局唯一)。
21static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
22
23/// FormSelect 紧凑触发器:工具栏内联小下拉(自动刷新、导出格式等)。
24pub const FORM_SELECT_COMPACT_CLASS: &str = "field field-sm w-auto";
25
26/// 选项多于该数量时,下拉面板顶部出现筛选框。
27const FILTER_THRESHOLD: usize = 8;
28
29/// 分配实例序号,用于拼接未显式传 `id` 时的元素 id(`form-select-N` 等)。
30///
31/// 序号在挂载后(Effect)才分配:全局计数器在服务端是进程级的,直接写进 SSR HTML
32/// 会与水合后的客户端计数不一致,导致 `get_element_by_id` 找不到元素。因此服务端
33/// 与水合首帧都是 `None`,对应的 id 属性随之缺省;弹层都在用户交互后才出现,届时序号已就绪。
34fn 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/// 面板应向上展开的条件:视口下方空间不足,且上方空间比下方更宽余。
45///
46/// 纯函数便于单测;`panel_height` 由调用方按选项数估算(见 `measure_flip`)。
47#[allow(dead_code)] // server 构建下仅被 dead 的组件体引用(wasm 与单测为真实调用方)
48fn should_flip(
49    trigger_top: f64,
50    trigger_bottom: f64,
51    viewport_height: f64,
52    panel_height: f64,
53) -> bool {
54    /// 面板与触发器的间隙(6px)加视口边缘留白。
55    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
61/// 键盘导航的循环索引:在 `len` 个选项中从 `cur` 移动 `delta`(±1),越界回绕。
62fn 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
69/// 在可选项(已筛选、未禁用)之间移动高亮;当前项不在其中时落到首项或末项。
70fn 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
81/// 筛选:忽略大小写的子串匹配,空查询匹配全部。
82fn 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/// 测量触发器视口位置,决定面板展开方向(仅 wasm;SSR 无 DOM)。
88#[cfg(target_arch = "wasm32")]
89pub(crate) fn measure_flip(trigger_id: &str, option_count: usize) -> bool {
90    /// 选项行高:20px 行盒 + 上下各 8px 内边距。
91    const ROW_HEIGHT: f64 = 36.0;
92    /// 面板内边距 4px ×2。
93    const PANEL_CHROME: f64 = 8.0;
94    /// 面板高度上限:18rem。
95    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/// 把指定元素滚入所在面板的可视区(打开/键盘导航时跟随;仅 wasm)。
117#[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/// 把指定元素滚到所在列的中间(时间列打开时;仅 wasm)。
131#[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/// 按 id 聚焦元素(仅 wasm)。
145#[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/// 下拉选择框组件(自定义弹层,全主题化)。
158///
159/// 原生 `<select>` 的弹出列表由 OS/浏览器渲染,无法跟随主题(暗色下是白底
160/// 系统菜单),故用 `button[aria-haspopup=listbox]` + 绝对定位面板重写:
161/// - 面板从触发器下方展开(snappy 弹簧,0.98 → 1),视口下方不够时向上展开;
162/// - 焦点留在触发器,键盘经 `aria-activedescendant` 高亮(↑↓ 循环、
163///   Enter/Space 选中、Esc 关闭、Home/End 跳首尾、Tab 关闭并自然流转焦点);
164/// - 选项多于 8 个时面板顶部出现筛选框,打开即聚焦,键盘规则相同,Esc 把焦点还给触发器;
165/// - `disabled_options` 中的项保留但变淡,并在下方写明原因,键盘与点击都跳过;
166/// - 透明遮罩拦截外部点击关闭;选项 `mousedown` 阻止默认行为,点击不夺走焦点;
167/// - 泛型值绑定:`onchange` 直接回传选中项的 `T`,无需字符串反查。
168///
169/// 触发器与选项的内部 `id`(`form-select-N-opt-I` 等)在挂载后由客户端计数分配,
170/// 原因见 `use_instance_prefix`;需要与 label 关联时显式传 `id`。
171///
172/// Props:
173/// - `id`:触发器 id,用于与 label 关联(缺省挂载后用内部计数器生成)
174/// - `value`:当前选中项(受控,可传信号)
175/// - `options`:可选项 `(值, 标签)` 列表(`Vec` 或返回它的闭包 / 信号)
176/// - `disabled_options`:不可用的项及原因 `(值, 原因)`
177/// - `onchange`:选中变化回调,回传新选中项的值
178/// - `trigger_class`:触发器外观(缺省全宽 `field`;工具栏传 [`FORM_SELECT_COMPACT_CLASS`])
179/// - `aria_label`:触发器无障碍标签;可见文本不足以表意时传入
180///
181/// `value` / `onchange` 的类型参数要能从 `options` 或 `onchange` 闭包的参数类型推断;
182/// 传裸信号且闭包参数无标注时,给闭包写类型(`move |v: Foo| ..`)。
183#[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    // 受控选中序号;value 不在 options 时兜底 0(与浏览器默认选中第一项一致)。
212    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    // 每项的禁用原因;与 options 一一对应。
225    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    // 打开或键盘导航时,把高亮项滚入面板可视区;带筛选框时打开即聚焦筛选框。
254    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    // 选中第 i 项:关闭面板、回调、焦点还给触发器。
290    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    // 面板打开后的键盘处理;触发器与筛选框共用。
299    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(&current)) {
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                    // 打开态下触发器被透明遮罩盖住,点击落在遮罩上即关闭;
354                    // 这里只需处理「未开 → 开」。
355                    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                            // 透明遮罩:拦截外部点击(点遮罩即关),z-40 < 面板 z-50。
387                            <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/// 下拉面板中的一项。禁用项(`reason` 为 Some)变淡、写明原因,不响应点击。
487#[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            // 阻止 mousedown 默认行为:点击选项不夺走焦点。
506            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
525/// 解析 "HH:MM" 为 (时, 分);任一段缺失或越界整体回退 (0, 0)。
526/// 纯防御性兜底:正常路径下 value 只来自服务端 normalize 或本组件输出,必然合法。
527fn 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/// 时间选择器(24 小时制 "HH:MM",浏览器本地时间)。
544///
545/// 触发器显示当前时间与「本地时间」;点开是小时、分钟两列的浮层,点选即回调,
546/// 底部给出对应的 UTC 时间(服务端以 UTC 存储,换算由调用方在保存时完成)。
547/// Esc 或点外部关闭,焦点回到触发器;列内 ↑↓ 逐格调整。
548///
549/// Props:
550/// - `id`:触发器 id,用于与 label 关联(缺省挂载后用内部计数器生成)
551/// - `value`:当前值 "HH:MM"(受控;非法值回退显示 00:00,不 panic)
552/// - `onchange`:选中变化回调,回传组合后的 "HH:MM"
553#[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    // 打开时把当前小时、分钟滚到列中间(避开上下渐隐)。
571    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    // UTC 换算依赖浏览器时区,仅在客户端(打开浮层后)显示。
582    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/// 表单输入框组件。
726///
727/// Props:
728/// - `id`:input 元素 id,用于与 label 关联
729/// - `r#type`:input 类型(缺省 `"text"`;如 `"email"`、`"password"`、`"number"`)
730/// - `placeholder`:占位提示文本(可选,不填则不输出属性)
731/// - `value`:当前值(受控,传信号或 `move ||` 闭包)
732/// - `disabled`:是否禁用(可选,缺省 `false`)
733/// - `invalid`:是否校验未通过(红环,输出 `aria-invalid`)
734/// - `required`:是否必填(缺省 `false`)
735/// - `readonly`:是否只读(缺省 `false`)
736/// - `autofocus`:挂载时是否自动聚焦(缺省 `false`)
737/// - `maxlength`:最大字符数(可选)
738/// - `autocomplete`:浏览器自动填充提示(可选,如 `"new-password"`)
739/// - `min` / `step`:number 类输入的下限与步进(可选)
740/// - `aria_describedby`:关联说明元素 id(可选)
741/// - `oninput`:输入事件回调,返回新的字符串值(可选;只读框不需要)
742/// - `onkeydown` / `onfocus` / `onblur`:可选的键盘与焦点事件回调
743/// - `class`:自定义 class(可选,缺省用 [`INPUT_CLASS`] 全宽表单款)。
744///   内联/固定宽度场景传自定义类串,覆盖默认样式。
745/// - `mono`:是否使用等宽字体(代码片段、表名、JSON 等,缺省 `false`)
746/// - `inputmode` / `title` / `aria_label`:可选的原生属性
747#[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/// 多行输入框:外观同 FormInput,`.field-textarea` 随内容长高,
829/// `rows` 是不支持 field-sizing 时的初始行数。
830#[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/// 数字输入框:单位写在框内右侧,两侧 −/+ 步进;越界时边框与说明一起变红。
858///
859/// 输入过程中保留草稿文本,只有在 `[min, max]` 内的整数才回调 `onchange`;
860/// 越界或非数字时显示红环与范围说明,不改动父组件的值。
861///
862/// Props:
863/// - `id`:input id,用于与 label 关联(缺省挂载后用内部计数器生成)
864/// - `value`:当前值(受控,可传信号)
865/// - `min` / `max`:允许范围(含端点)
866/// - `step`:步进,缺省 1
867/// - `unit`:单位,如「天」「次/分钟」
868/// - `hint`:范围内时显示的说明(可选)
869/// - `aria_label`:没有可见标签时的无障碍名称(可选)
870/// - `onchange`:合法新值回调
871#[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    // 父组件改值(外部重置、保存回填)时同步草稿。
891    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
987/// 解析草稿文本,在 `[min, max]` 内才返回。
988fn 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/// 表单标签组件。
996///
997/// Props:
998/// - `label`:标签文本
999/// - `html_for`:关联的 input id
1000#[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/// 提示条,用于显示成功、错误等状态消息。
1013///
1014/// Props:
1015/// - `message`:提示文本
1016/// - `variant`:`"error"`、`"success"`,其他值为中性说明
1017#[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/// 开关(toggle switch)组件。
1045///
1046/// 设置项的布尔切换:轨道 36×22,开启陶土色;圆点以 bouncy 弹簧滑动。
1047/// accessibility:`role="switch"` + `aria-checked`,键盘焦点陶土双环。
1048///
1049/// Props:
1050/// - `checked`:当前开关状态(可传信号)
1051/// - `ontoggle`:点击切换回调(父组件在回调内翻转 signal 并触发副作用)
1052/// - `aria_label`:没有可见标签时的无障碍名称
1053/// - `disabled`:是否禁用(缺省 `false`)
1054#[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)); // 空串
1091        assert_eq!(parse_hhmm("12"), (0, 0)); // 缺分钟段
1092        assert_eq!(parse_hhmm("24:00"), (0, 0)); // 小时越界
1093        assert_eq!(parse_hhmm("12:60"), (0, 0)); // 分钟越界
1094        assert_eq!(parse_hhmm("ab:cd"), (0, 0)); // 非数字
1095    }
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); // 末尾前进回绕到首
1101        assert_eq!(wrap_index(0, -1, 3), 2); // 首位后退回绕到尾
1102        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); // 空列表不越界
1108    }
1109
1110    #[test]
1111    fn should_flip_only_when_below_insufficient_and_above_wider() {
1112        // 下方充足:不翻(below = 800-140 = 660 > 200+14)
1113        assert!(!should_flip(100.0, 140.0, 800.0, 200.0));
1114        // 下方不足且上方更宽:上翻(below = 160 < 214,above = 600 > 160)
1115        assert!(should_flip(600.0, 640.0, 800.0, 200.0));
1116        // 下方不足但上方更窄:保持向下(above = 30 < below = 190)
1117        assert!(!should_flip(30.0, 70.0, 260.0, 200.0));
1118        // 恰好放得下(below = 214 == 200+14,非严格小于):不翻
1119        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        // 可选项为 1、3、4(0、2 被筛掉或禁用)
1125        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)); // 末尾回绕
1128        assert_eq!(step_active(&navigable, 1, -1), Some(4)); // 首位回绕
1129                                                             // 当前项被筛掉:向下落到首项,向上落到末项
1130        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    // ---- SSR 渲染(Leptos):覆盖各组件在常见传参写法下的静态结构 ----
1154
1155    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    /// 页面里常见的 FormSelect 传参写法都要能编译并推断出类型参数。
1297    #[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                // 信号值 + 常量表 + 无标注闭包参数
1310                <FormSelect value=refresh options=REFRESH.to_vec() onchange=move |v| refresh.set(v)/>
1311                // 闭包值 + 响应式选项 + 标注参数
1312                <FormSelect
1313                    value=move || tag.get()
1314                    options=move || tags.get()
1315                    onchange=move |v: String| tag.set(v)
1316                />
1317                // Option 值 + 内联字面量选项
1318                <FormSelect
1319                    value=published
1320                    options=vec![(None, "公开状态"), (Some(true), "已公开"), (Some(false), "私密")]
1321                    onchange={move |v: Option<bool>| published.set(v)}
1322                />
1323                // 整数字面量值 + 禁用项
1324                <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        // "4:05" 可解析为 (4, 5),补零后显示。
1345        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}