Skip to main content

yggdrasil/components/
forms.rs

1//! 表单控件组件
2//!
3//! 提供登录、注册、评论等页面共享的输入框、按钮与提示框样式常量与组件。
4
5use dioxus::prelude::*;
6
7/// 输入框基础 CSS 类,统一文本框、邮箱框、URL 框等样式。
8pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
9
10/// 内联输入框 CSS 类:与 [`INPUT_CLASS`] 同主题,但用 `flex-1 min-w-0` 取代 `w-full`,
11/// 用于与按钮并排、需填充剩余宽度的场景(搜索栏、URL 输入栏等)。
12pub const INPUT_INLINE_CLASS: &str = "flex-1 min-w-0 px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
13
14/// 可清除输入框 CSS 类:与 [`INPUT_CLASS`] 同主题(`w-full` 撑满外层 relative 包裹),
15/// 右侧 `pr-10` 为输入框内的自定义清除按钮(Material Symbols `close` 图标)让位,
16/// `ygg-search-clear` 钩子隐藏 WebKit/Blink 原生 `::-webkit-search-cancel-button`
17/// (见 input.css)。用于「输入框内带清除图标」场景(/search 页)。
18pub const INPUT_SEARCH_CLASS: &str = "w-full pr-10 px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200 ygg-search-clear";
19
20/// 主按钮 CSS 类,用于表单提交等主操作按钮。
21pub const BUTTON_PRIMARY_CLASS: &str = "w-full py-2.5 px-4 bg-paper-accent text-white font-medium rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer";
22
23/// FormSelect 实例 id 计数器(跨泛型单例化全局唯一)。
24static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
25
26/// FormSelect 紧凑触发器样式:工具栏内联小下拉(自动刷新、导出格式等)。
27/// 自动宽度 + text-sm + 小圆角,chevron 与面板样式与默认表单款一致。
28pub const FORM_SELECT_COMPACT_CLASS: &str = "inline-flex w-auto cursor-pointer select-none text-left text-sm pl-3 pr-8 py-1 border border-paper-border rounded-lg bg-paper-theme text-paper-primary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
29
30/// 面板应向上展开的条件:视口下方空间不足,且上方空间比下方更宽余。
31///
32/// 纯函数便于单测;`panel_height` 由调用方按选项数估算(见 `measure_flip`)。
33#[allow(dead_code)] // server 构建下仅被 dead 的组件体引用(wasm 与单测为真实调用方)
34fn should_flip(
35    trigger_top: f64,
36    trigger_bottom: f64,
37    viewport_height: f64,
38    panel_height: f64,
39) -> bool {
40    /// 面板与触发器的间隙(mt-1.5)加视口边缘留白。
41    const MARGIN: f64 = 14.0;
42    let below = viewport_height - trigger_bottom;
43    let above = trigger_top;
44    below < panel_height + MARGIN && above > below
45}
46
47/// 键盘导航的循环索引:在 `len` 个选项中从 `cur` 移动 `delta`(±1),越界回绕。
48fn wrap_index(cur: usize, delta: i32, len: usize) -> usize {
49    if len == 0 {
50        return 0;
51    }
52    (cur as i32 + delta).rem_euclid(len as i32) as usize
53}
54
55/// 测量触发器视口位置,决定面板展开方向(仅 wasm;SSR 无 DOM)。
56#[cfg(target_arch = "wasm32")]
57pub(crate) fn measure_flip(trigger_id: &str, option_count: usize) -> bool {
58    /// 选项行高:24px 行盒 + py-2.5(20px 垂直内边距)。
59    const ROW_HEIGHT: f64 = 44.0;
60    /// 面板 1px 边框 ×2 + p-1.5 内边距。
61    const PANEL_CHROME: f64 = 14.0;
62    /// 面板高度上限:max-h-60(240px)+ PANEL_CHROME。
63    const PANEL_MAX: f64 = 254.0;
64
65    let Some(window) = web_sys::window() else {
66        return false;
67    };
68    let Some(document) = window.document() else {
69        return false;
70    };
71    let Some(el) = document.get_element_by_id(trigger_id) else {
72        return false;
73    };
74    let rect = el.get_bounding_client_rect();
75    let viewport = window
76        .inner_height()
77        .ok()
78        .and_then(|v| v.as_f64())
79        .unwrap_or(800.0);
80    let panel_height = ((option_count as f64) * ROW_HEIGHT + PANEL_CHROME).min(PANEL_MAX);
81    should_flip(rect.top(), rect.bottom(), viewport, panel_height)
82}
83
84/// 把指定选项滚入面板可视区(打开/键盘导航时跟随;仅 wasm)。
85#[cfg(target_arch = "wasm32")]
86fn scroll_option_into_view(element_id: &str) {
87    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
88        return;
89    };
90    let Some(el) = document.get_element_by_id(element_id) else {
91        return;
92    };
93    let opts = web_sys::ScrollIntoViewOptions::new();
94    opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
95    el.scroll_into_view_with_scroll_into_view_options(&opts);
96}
97
98/// 下拉选择框组件(自定义弹层,全主题化)。
99///
100/// 原生 `<select>` 的弹出列表由 OS/浏览器渲染,无法跟随主题(暗色下是白底
101/// 系统菜单),故用 `button[aria-haspopup=listbox]` + 绝对定位面板重写:
102/// - 面板/选项全部使用 Catppuccin 语义色,带 `select-enter` 入场动画;
103/// - focus 始终留在触发器,键盘经 `aria-activedescendant` 高亮(↑↓ 循环、
104///   Enter/Space 选中、Esc 关闭、Home/End 跳首尾、Tab 关闭并自然流转焦点);
105/// - 透明遮罩拦截外部点击关闭(同 Popover 模式);选项 `onmousedown` 阻止默认
106///   行为,点击不夺走触发器焦点;
107/// - 打开时视口下方空间不足且上方更宽余则向上展开(`should_flip`);
108/// - 泛型值绑定:`onchange` 直接回传选中项的 `T`,无需字符串反查。
109///
110/// Props:
111/// - `id`:触发器 id,用于与 label 关联(缺省用内部计数器生成)
112/// - `value`:当前选中项(受控)
113/// - `options`:可选项 `(值, 标签)` 列表
114/// - `onchange`:选中变化回调,回传新选中项的值
115/// - `aria_label`:触发器无障碍标签(可选);可见文本不足以表意时传入,
116///   如 TimePicker 的「小时」/「分钟」
117#[component]
118pub fn FormSelect<T: Clone + PartialEq + 'static>(
119    id: Option<String>,
120    value: T,
121    options: Vec<(T, &'static str)>,
122    onchange: EventHandler<T>,
123    /// 触发器样式覆盖:缺省为全宽表单款;工具栏内联场景传
124    /// [`FORM_SELECT_COMPACT_CLASS`],或自定义类串(如编辑器底部胶囊)。
125    #[props(default)]
126    trigger_class: Option<&'static str>,
127    /// 触发器无障碍标签:缺省不加 aria-label(触发器可见文本即标签)。
128    #[props(default)]
129    aria_label: Option<&'static str>,
130) -> Element {
131    // 面板与 POPOVER_PANEL_CLASS 同源(卡片化圆角 + 阴影)。宽度取 max(触发器,
132    // 最长选项):紧凑触发器(如“手动”)下面板仍能完整展示长选项;上限防出屏。
133    // 水平以触发器中心居中:面板宽于触发器时两侧对称探出,窄触发器不失衡。
134    // 居中用 [transform:translateX(-50%)] 而非 -translate-x-1/2 utility:Tailwind
135    // v4 的 translate utility 走独立 translate 属性,会与 select-enter 关键帧的
136    // transform 叠加造成双倍位移;关键帧的 fill 值与此处 transform 完全一致。
137    // 定义在函数体内:模块级私有常量若仅被 wasm 门控调用点引用,会在 server
138    // 构建下触发 dead_code。
139    const TRIGGER_CLASS: &str = "w-full block cursor-pointer truncate select-none text-left pl-4 pr-10 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
140    const PANEL_CLASS: &str = "absolute left-1/2 z-50 w-max min-w-full max-w-[calc(100vw_-_2rem)] [transform:translateX(-50%)] max-h-60 overflow-y-auto rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] p-1.5 shadow-lg animate-select-enter";
141
142    let trigger_cls = trigger_class.unwrap_or(TRIGGER_CLASS);
143
144    let id_prefix = use_hook(|| FORM_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
145
146    // 受控选中序号;value 不在 options 时兜底 0(与浏览器默认选中第一项一致)。
147    let selected = options.iter().position(|(v, _)| *v == value).unwrap_or(0);
148    let selected_label = options.get(selected).map(|(_, l)| *l).unwrap_or_default();
149    let len = options.len();
150
151    let mut open = use_signal(|| false);
152    let mut active = use_signal(|| selected);
153    // flip_up 的 set 均在 wasm 门控语句内(宿主构建下只读),参照 FilterTabs 先例。
154    #[allow(unused_mut)]
155    let mut flip_up = use_signal(|| false);
156
157    // onkeydown 闭包与下方选项渲染各需一份 options(键盘选中回查 / 渲染)。
158    let options_for_keys = options.clone();
159
160    // 触发器 id:外部未指定时用内部前缀生成,ARIA 关联统一走它。
161    let trigger_id = id.unwrap_or_else(|| format!("form-select-{id_prefix}"));
162    // 两个事件闭包各持一份(仅 wasm 用于 flip 测量按 id 查元素)。
163    #[cfg(target_arch = "wasm32")]
164    let trigger_id_click = trigger_id.clone();
165    #[cfg(target_arch = "wasm32")]
166    let trigger_id_keys = trigger_id.clone();
167
168    // 打开或键盘导航时,把高亮项滚入面板可视区。
169    use_effect(move || {
170        if open() {
171            #[cfg(target_arch = "wasm32")]
172            {
173                let idx = active();
174                scroll_option_into_view(&format!("form-select-{id_prefix}-opt-{idx}"));
175            }
176        }
177    });
178
179    // 预计算每行展示态,rsx 循环内只做移动与闭包捕获。
180    let active_idx = active();
181    let rows: Vec<(usize, T, &'static str, &'static str, &'static str)> = options
182        .iter()
183        .enumerate()
184        .map(|(i, (v, l))| {
185            let highlight = if i == active_idx {
186                "bg-[var(--color-paper-accent-soft)]"
187            } else {
188                ""
189            };
190            let text = if i == selected {
191                "text-paper-accent"
192            } else {
193                "text-[var(--color-paper-primary)]"
194            };
195            (i, v.clone(), *l, highlight, text)
196        })
197        .collect();
198
199    let chevron_rotate = if open() { "rotate-180" } else { "" };
200    let placement_cls = if flip_up() {
201        "bottom-full mb-1.5 origin-bottom"
202    } else {
203        "top-full mt-1.5 origin-top"
204    };
205    let active_descendant = open().then(|| {
206        let idx = active();
207        format!("form-select-{id_prefix}-opt-{idx}")
208    });
209
210    rsx! {
211        div { class: "relative",
212            button {
213                id: "{trigger_id}",
214                r#type: "button",
215                class: "{trigger_cls}",
216                aria_haspopup: "listbox",
217                aria_expanded: "{open()}",
218                aria_activedescendant: active_descendant,
219                aria_label,
220                onclick: move |_| {
221                    // 打开态下触发器被透明遮罩盖住,点击落在遮罩上即关闭;
222                    // 这里只需处理「未开 → 开」。
223                    if !open() {
224                        #[cfg(target_arch = "wasm32")]
225                        flip_up.set(measure_flip(&trigger_id_click, len));
226                        active.set(selected);
227                        open.set(true);
228                    }
229                },
230                onkeydown: move |e| {
231                    let key = e.key();
232                    let is_space = matches!(&key, Key::Character(s) if s == " ");
233                    if !open() {
234                        if key == Key::ArrowDown || key == Key::ArrowUp || key == Key::Enter
235                            || is_space
236                        {
237                            e.prevent_default();
238                            #[cfg(target_arch = "wasm32")]
239                            flip_up.set(measure_flip(&trigger_id_keys, len));
240                            active.set(selected);
241                            open.set(true);
242                        }
243                        return;
244                    }
245                    if key == Key::ArrowDown {
246                        e.prevent_default();
247                        active.set(wrap_index(active(), 1, len));
248                    } else if key == Key::ArrowUp {
249                        e.prevent_default();
250                        active.set(wrap_index(active(), -1, len));
251                    } else if key == Key::Home { // 不拦截:关闭后焦点自然流转到下一个控件。
252                        e.prevent_default();
253                        active.set(0);
254                    } else if key == Key::End {
255                        e.prevent_default();
256                        active.set(len.saturating_sub(1));
257                    } else if key == Key::Enter || is_space {
258                        e.prevent_default();
259                        if let Some((v, _)) = options_for_keys.get(active()) {
260                            onchange.call(v.clone());
261                        }
262                        open.set(false);
263                    } else if key == Key::Escape {
264                        e.prevent_default();
265                        open.set(false);
266                    } else if key == Key::Tab {
267                        open.set(false);
268                    }
269                },
270                "{selected_label}"
271                // 下拉箭头(打开时翻转)
272                svg {
273                    class: "pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-paper-secondary transition-transform duration-200 {chevron_rotate}",
274                    view_box: "0 0 24 24",
275                    fill: "none",
276                    stroke: "currentColor",
277                    stroke_width: "2",
278                    path {
279                        stroke_linecap: "round",
280                        stroke_linejoin: "round",
281                        d: "M6 9l6 6 6-6",
282                    }
283                }
284            }
285
286            if open() {
287                // 透明遮罩:拦截外部点击(点遮罩即关),z-40 < 面板 z-50。
288                div {
289                    class: "fixed inset-0 z-40",
290                    onclick: move |_| open.set(false),
291                }
292                ul {
293                    class: "{PANEL_CLASS} {placement_cls}",
294                    role: "listbox",
295                    aria_labelledby: "{trigger_id}",
296                    for (i, opt_value, opt_label, highlight_cls, text_cls) in rows {
297                        li {
298                            id: "form-select-{id_prefix}-opt-{i}",
299                            class: "flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl cursor-pointer select-none transition-colors hover:bg-[var(--color-paper-accent-soft)] {text_cls} {highlight_cls}",
300                            role: "option",
301                            aria_selected: "{i == selected}",
302                            // 阻止 mousedown 默认行为:点击选项不夺走触发器焦点。
303                            onmousedown: move |e| e.prevent_default(),
304                            onclick: move |_| {
305                                onchange.call(opt_value.clone());
306                                open.set(false);
307                            },
308                            onmouseenter: move |_| active.set(i),
309                            span { class: "truncate", "{opt_label}" }
310                            if i == selected {
311                                svg {
312                                    class: "w-4 h-4 flex-shrink-0",
313                                    view_box: "0 0 24 24",
314                                    fill: "none",
315                                    stroke: "currentColor",
316                                    stroke_width: "2",
317                                    path {
318                                        stroke_linecap: "round",
319                                        stroke_linejoin: "round",
320                                        d: "M20 6L9 17l-5-5",
321                                    }
322                                }
323                            }
324                        }
325                    }
326                }
327            }
328        }
329    }
330}
331
332/// 小时/分钟选项标签表("00"–"23" / "00"–"59")。
333/// 静态表避免每次渲染堆分配;FormSelect 选项标签要求 `&'static str`。
334const HOUR_LABELS: [&str; 24] = [
335    "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15",
336    "16", "17", "18", "19", "20", "21", "22", "23",
337];
338const MINUTE_LABELS: [&str; 60] = [
339    "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15",
340    "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31",
341    "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47",
342    "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
343];
344
345/// 解析 "HH:MM" 为 (时, 分);任一段缺失或越界整体回退 (0, 0)。
346/// 纯防御性兜底:正常路径下 value 只来自服务端 normalize 或本组件输出,必然合法。
347fn parse_hhmm(value: &str) -> (u8, u8) {
348    let mut parts = value.split(':');
349    let hour = parts
350        .next()
351        .and_then(|s| s.parse::<u8>().ok())
352        .filter(|h| *h < 24);
353    let minute = parts
354        .next()
355        .and_then(|s| s.parse::<u8>().ok())
356        .filter(|m| *m < 60);
357    match (hour, minute) {
358        (Some(h), Some(m)) => (h, m),
359        _ => (0, 0),
360    }
361}
362
363/// 时间选择器(24 小时制 "HH:MM")。
364///
365/// 原生 `<input type="time">` 的弹出层由浏览器/OS 绘制,暗色主题下是白底
366/// 系统菜单(与原生 `<select>` 同款问题),故用两个 [`FormSelect`](时/分)
367/// 组合重写,弹层配色、`select-enter` 动画、键盘导航、视口翻转与遮罩关闭
368/// 逻辑全部继承:
369/// - 外框容器镜像步进器控件(rounded-lg 边框 + `bg-paper-entry`),触发器无边框;
370/// - 打开任一段下拉自动滚动到当前值,点击即回调组合后的 "HH:MM";
371/// - 键盘:Tab 进入时/分列,↑↓ 或 Enter 展开,Enter 选定,Esc 关闭。
372///
373/// Props:
374/// - `id`:小时触发器 id,用于与 label 关联(缺省用内部计数器生成)
375/// - `value`:当前值 "HH:MM"(受控;非法值回退显示 00:00,不 panic)
376/// - `onchange`:选中变化回调,回传组合后的 "HH:MM"
377#[component]
378pub fn TimePicker(id: Option<String>, value: String, onchange: EventHandler<String>) -> Element {
379    // 无边框紧凑触发器:外框由容器统一绘制(镜像「保留份数」步进器)。
380    // 定义在函数体内:模块级私有常量若仅被 wasm 门控调用点引用,会在 server
381    // 构建下触发 dead_code(同 FormSelect 的 TRIGGER_CLASS 先例)。
382    const TIME_TRIGGER_CLASS: &str = "inline-flex w-auto cursor-pointer select-none text-sm tabular-nums pl-2.5 pr-8 py-2 rounded-md bg-transparent text-paper-primary focus:outline-none focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
383
384    let (hour, minute) = parse_hhmm(&value);
385    let hour_options: Vec<(u8, &'static str)> = HOUR_LABELS
386        .iter()
387        .enumerate()
388        .map(|(i, l)| (i as u8, *l))
389        .collect();
390    let minute_options: Vec<(u8, &'static str)> = MINUTE_LABELS
391        .iter()
392        .enumerate()
393        .map(|(i, l)| (i as u8, *l))
394        .collect();
395
396    rsx! {
397        div { class: "inline-flex items-center gap-0.5 rounded-lg border border-paper-border bg-paper-entry",
398            FormSelect {
399                id,
400                aria_label: "小时",
401                value: hour,
402                options: hour_options,
403                trigger_class: TIME_TRIGGER_CLASS,
404                onchange: move |h: u8| onchange.call(format!("{h:02}:{minute:02}")),
405            }
406            span { class: "text-sm text-paper-tertiary select-none", ":" }
407            FormSelect {
408                aria_label: "分钟",
409                value: minute,
410                options: minute_options,
411                trigger_class: TIME_TRIGGER_CLASS,
412                onchange: move |m: u8| onchange.call(format!("{hour:02}:{m:02}")),
413            }
414        }
415    }
416}
417
418/// 表单输入框组件。
419///
420/// Props:
421/// - `id`:input 元素 id,用于与 label 关联
422/// - `r#type`:input 类型(如 `"text"`、`"email"`、`"password"`)
423/// - `placeholder`:占位提示文本
424/// - `value`:当前值
425/// - `disabled`:是否禁用(可选,缺省 `false`)
426/// - `oninput`:输入事件回调,返回新的字符串值
427/// - `onkeydown`:可选的键盘事件回调
428/// - `class`:自定义 class(可选,缺省用 [`INPUT_CLASS`] 全宽表单款)。
429///   内联/固定宽度场景传 [`INPUT_INLINE_CLASS`] 或自定义类串,覆盖默认样式。
430/// - `mono`:是否使用等宽字体(代码片段、表名、JSON 等,缺省 `false`)
431#[component]
432pub fn FormInput(
433    id: Option<String>,
434    r#type: &'static str,
435    placeholder: &'static str,
436    value: String,
437    #[props(default)] disabled: bool,
438    oninput: EventHandler<String>,
439    #[props(default)] onkeydown: Option<EventHandler<KeyboardEvent>>,
440    #[props(default)] class: Option<&'static str>,
441    #[props(default)] mono: bool,
442) -> Element {
443    let base = class.unwrap_or(INPUT_CLASS);
444    let mono_class = if mono { " font-mono" } else { "" };
445    let disabled_class = if disabled {
446        " opacity-60 cursor-not-allowed"
447    } else {
448        ""
449    };
450    rsx! {
451        input {
452            id: id.unwrap_or_default(),
453            class: "{base}{mono_class}{disabled_class}",
454            r#type: "{r#type}",
455            placeholder: "{placeholder}",
456            value: "{value}",
457            disabled,
458            oninput: move |e| oninput.call(e.value()),
459            onkeydown: move |e| {
460                if let Some(ref handler) = onkeydown {
461                    handler.call(e);
462                }
463            },
464        }
465    }
466}
467
468/// 表单标签组件。
469///
470/// Props:
471/// - `label`:标签文本
472/// - `html_for`:关联的 input id
473#[component]
474pub fn FormLabel(label: String, html_for: Option<String>) -> Element {
475    rsx! {
476        label {
477            class: "block text-sm font-medium text-paper-secondary mb-1",
478            r#for: html_for.unwrap_or_default(),
479            "{label}"
480        }
481    }
482}
483
484/// 提示框组件,用于显示成功、错误等状态消息。
485///
486/// Props:
487/// - `message`:提示文本
488/// - `variant`:风格类型,支持 `"error"`、`"success"` 与其他默认类型
489#[component]
490pub fn AlertBox(message: String, variant: &'static str) -> Element {
491    let (bg_class, text_class) = match variant {
492        "error" => (
493            "bg-red-100 dark:bg-red-900/30",
494            "text-red-700 dark:text-red-300",
495        ),
496        "success" => (
497            "bg-green-100 dark:bg-green-900/30",
498            "text-green-700 dark:text-green-300",
499        ),
500        _ => ("bg-paper-code-bg", "text-paper-secondary"),
501    };
502    rsx! {
503        div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", "{message}" }
504    }
505}
506
507/// 开关(toggle switch)组件。
508///
509/// 自定义滑块开关,取代原生 checkbox 用于设置项的布尔切换。视觉与交互全站统一:
510/// 轨道 44×24px,开启主题绿、关闭 paper-tertiary;圆点 20px 白色,开启右移 20px。
511/// accessibility:`role="switch"` + `aria-checked`,键盘 focus-visible 描边。
512///
513/// Props:
514/// - `checked`:当前开关状态
515/// - `ontoggle`:点击切换回调(父组件在回调内翻转 signal 并触发副作用)
516#[component]
517pub fn ToggleSwitch(checked: bool, ontoggle: Callback<()>) -> Element {
518    let track_class = if checked {
519        "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-accent cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40"
520    } else {
521        "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-tertiary cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40"
522    };
523    let thumb_class = if checked {
524        "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200 translate-x-5"
525    } else {
526        "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200"
527    };
528    rsx! {
529        button {
530            role: "switch",
531            aria_checked: "{checked}",
532            class: "{track_class}",
533            onclick: move |_| ontoggle.call(()),
534            span { class: "{thumb_class}" }
535        }
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::{parse_hhmm, should_flip, wrap_index};
542
543    #[test]
544    fn parse_hhmm_valid_values() {
545        assert_eq!(parse_hhmm("00:00"), (0, 0));
546        assert_eq!(parse_hhmm("04:30"), (4, 30));
547        assert_eq!(parse_hhmm("23:59"), (23, 59));
548    }
549
550    #[test]
551    fn parse_hhmm_invalid_falls_back_to_zero() {
552        assert_eq!(parse_hhmm(""), (0, 0)); // 空串
553        assert_eq!(parse_hhmm("12"), (0, 0)); // 缺分钟段
554        assert_eq!(parse_hhmm("24:00"), (0, 0)); // 小时越界
555        assert_eq!(parse_hhmm("12:60"), (0, 0)); // 分钟越界
556        assert_eq!(parse_hhmm("ab:cd"), (0, 0)); // 非数字
557    }
558
559    #[test]
560    fn wrap_index_cycles_both_directions() {
561        assert_eq!(wrap_index(0, 1, 3), 1);
562        assert_eq!(wrap_index(2, 1, 3), 0); // 末尾前进回绕到首
563        assert_eq!(wrap_index(0, -1, 3), 2); // 首位后退回绕到尾
564        assert_eq!(wrap_index(1, -1, 3), 0);
565    }
566
567    #[test]
568    fn wrap_index_empty_is_zero() {
569        assert_eq!(wrap_index(5, 1, 0), 0); // 空列表不越界
570    }
571
572    #[test]
573    fn should_flip_only_when_below_insufficient_and_above_wider() {
574        // 下方充足:不翻(below = 800-140 = 660 > 200+14)
575        assert!(!should_flip(100.0, 140.0, 800.0, 200.0));
576        // 下方不足且上方更宽:上翻(below = 160 < 214,above = 600 > 160)
577        assert!(should_flip(600.0, 640.0, 800.0, 200.0));
578        // 下方不足但上方更窄:保持向下(above = 30 < below = 190)
579        assert!(!should_flip(30.0, 70.0, 260.0, 200.0));
580        // 恰好放得下(below = 214 == 200+14,非严格小于):不翻
581        assert!(!should_flip(300.0, 340.0, 554.0, 200.0));
582    }
583}