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