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 类,用于表单提交等主操作按钮。
15pub 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";
16
17/// FormSelect 实例 id 计数器(跨泛型单例化全局唯一)。
18static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
19
20/// FormSelect 紧凑触发器样式:工具栏内联小下拉(自动刷新、导出格式等)。
21/// 自动宽度 + text-sm + 小圆角,chevron 与面板样式与默认表单款一致。
22pub 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";
23
24/// 面板应向上展开的条件:视口下方空间不足,且上方空间比下方更宽余。
25///
26/// 纯函数便于单测;`panel_height` 由调用方按选项数估算(见 `measure_flip`)。
27#[allow(dead_code)] // server 构建下仅被 dead 的组件体引用(wasm 与单测为真实调用方)
28fn should_flip(
29    trigger_top: f64,
30    trigger_bottom: f64,
31    viewport_height: f64,
32    panel_height: f64,
33) -> bool {
34    /// 面板与触发器的间隙(mt-1.5)加视口边缘留白。
35    const MARGIN: f64 = 14.0;
36    let below = viewport_height - trigger_bottom;
37    let above = trigger_top;
38    below < panel_height + MARGIN && above > below
39}
40
41/// 键盘导航的循环索引:在 `len` 个选项中从 `cur` 移动 `delta`(±1),越界回绕。
42fn wrap_index(cur: usize, delta: i32, len: usize) -> usize {
43    if len == 0 {
44        return 0;
45    }
46    (cur as i32 + delta).rem_euclid(len as i32) as usize
47}
48
49/// 测量触发器视口位置,决定面板展开方向(仅 wasm;SSR 无 DOM)。
50#[cfg(target_arch = "wasm32")]
51fn measure_flip(trigger_id: &str, option_count: usize) -> bool {
52    /// 选项行高:24px 行盒 + py-2.5(20px 垂直内边距)。
53    const ROW_HEIGHT: f64 = 44.0;
54    /// 面板 1px 边框 ×2 + p-1.5 内边距。
55    const PANEL_CHROME: f64 = 14.0;
56    /// 面板高度上限:max-h-60(240px)+ PANEL_CHROME。
57    const PANEL_MAX: f64 = 254.0;
58
59    let Some(window) = web_sys::window() else {
60        return false;
61    };
62    let Some(document) = window.document() else {
63        return false;
64    };
65    let Some(el) = document.get_element_by_id(trigger_id) else {
66        return false;
67    };
68    let rect = el.get_bounding_client_rect();
69    let viewport = window
70        .inner_height()
71        .ok()
72        .and_then(|v| v.as_f64())
73        .unwrap_or(800.0);
74    let panel_height = ((option_count as f64) * ROW_HEIGHT + PANEL_CHROME).min(PANEL_MAX);
75    should_flip(rect.top(), rect.bottom(), viewport, panel_height)
76}
77
78/// 把指定选项滚入面板可视区(打开/键盘导航时跟随;仅 wasm)。
79#[cfg(target_arch = "wasm32")]
80fn scroll_option_into_view(element_id: &str) {
81    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
82        return;
83    };
84    let Some(el) = document.get_element_by_id(element_id) else {
85        return;
86    };
87    let opts = web_sys::ScrollIntoViewOptions::new();
88    opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
89    el.scroll_into_view_with_scroll_into_view_options(&opts);
90}
91
92/// 下拉选择框组件(自定义弹层,全主题化)。
93///
94/// 原生 `<select>` 的弹出列表由 OS/浏览器渲染,无法跟随主题(暗色下是白底
95/// 系统菜单),故用 `button[aria-haspopup=listbox]` + 绝对定位面板重写:
96/// - 面板/选项全部使用 Catppuccin 语义色,带 `select-enter` 入场动画;
97/// - focus 始终留在触发器,键盘经 `aria-activedescendant` 高亮(↑↓ 循环、
98///   Enter/Space 选中、Esc 关闭、Home/End 跳首尾、Tab 关闭并自然流转焦点);
99/// - 透明遮罩拦截外部点击关闭(同 Popover 模式);选项 `onmousedown` 阻止默认
100///   行为,点击不夺走触发器焦点;
101/// - 打开时视口下方空间不足且上方更宽余则向上展开(`should_flip`);
102/// - 泛型值绑定:`onchange` 直接回传选中项的 `T`,无需字符串反查。
103///
104/// Props:
105/// - `id`:触发器 id,用于与 label 关联(缺省用内部计数器生成)
106/// - `value`:当前选中项(受控)
107/// - `options`:可选项 `(值, 标签)` 列表
108/// - `onchange`:选中变化回调,回传新选中项的值
109#[component]
110pub fn FormSelect<T: Clone + PartialEq + 'static>(
111    id: Option<String>,
112    value: T,
113    options: Vec<(T, &'static str)>,
114    onchange: EventHandler<T>,
115    /// 触发器样式覆盖:缺省为全宽表单款;工具栏内联场景传
116    /// [`FORM_SELECT_COMPACT_CLASS`],或自定义类串(如编辑器底部胶囊)。
117    #[props(default)]
118    trigger_class: Option<&'static str>,
119) -> Element {
120    // 面板与 POPOVER_PANEL_CLASS 同源(卡片化圆角 + 阴影)。宽度取 max(触发器,
121    // 最长选项):紧凑触发器(如“手动”)下面板仍能完整展示长选项;上限防出屏。
122    // 水平以触发器中心居中:面板宽于触发器时两侧对称探出,窄触发器不失衡。
123    // 居中用 [transform:translateX(-50%)] 而非 -translate-x-1/2 utility:Tailwind
124    // v4 的 translate utility 走独立 translate 属性,会与 select-enter 关键帧的
125    // transform 叠加造成双倍位移;关键帧的 fill 值与此处 transform 完全一致。
126    // 定义在函数体内:模块级私有常量若仅被 wasm 门控调用点引用,会在 server
127    // 构建下触发 dead_code。
128    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";
129    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";
130
131    let trigger_cls = trigger_class.unwrap_or(TRIGGER_CLASS);
132
133    let id_prefix = use_hook(|| FORM_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
134
135    // 受控选中序号;value 不在 options 时兜底 0(与浏览器默认选中第一项一致)。
136    let selected = options.iter().position(|(v, _)| *v == value).unwrap_or(0);
137    let selected_label = options.get(selected).map(|(_, l)| *l).unwrap_or_default();
138    let len = options.len();
139
140    let mut open = use_signal(|| false);
141    let mut active = use_signal(|| selected);
142    // flip_up 的 set 均在 wasm 门控语句内(宿主构建下只读),参照 FilterTabs 先例。
143    #[allow(unused_mut)]
144    let mut flip_up = use_signal(|| false);
145
146    // onkeydown 闭包与下方选项渲染各需一份 options(键盘选中回查 / 渲染)。
147    let options_for_keys = options.clone();
148
149    // 触发器 id:外部未指定时用内部前缀生成,ARIA 关联统一走它。
150    let trigger_id = id.unwrap_or_else(|| format!("form-select-{id_prefix}"));
151    // 两个事件闭包各持一份(仅 wasm 用于 flip 测量按 id 查元素)。
152    #[cfg(target_arch = "wasm32")]
153    let trigger_id_click = trigger_id.clone();
154    #[cfg(target_arch = "wasm32")]
155    let trigger_id_keys = trigger_id.clone();
156
157    // 打开或键盘导航时,把高亮项滚入面板可视区。
158    use_effect(move || {
159        if open() {
160            #[cfg(target_arch = "wasm32")]
161            {
162                let idx = active();
163                scroll_option_into_view(&format!("form-select-{id_prefix}-opt-{idx}"));
164            }
165        }
166    });
167
168    // 预计算每行展示态,rsx 循环内只做移动与闭包捕获。
169    let active_idx = active();
170    let rows: Vec<(usize, T, &'static str, &'static str, &'static str)> = options
171        .iter()
172        .enumerate()
173        .map(|(i, (v, l))| {
174            let highlight = if i == active_idx {
175                "bg-[var(--color-paper-accent-soft)]"
176            } else {
177                ""
178            };
179            let text = if i == selected {
180                "text-paper-accent"
181            } else {
182                "text-[var(--color-paper-primary)]"
183            };
184            (i, v.clone(), *l, highlight, text)
185        })
186        .collect();
187
188    let chevron_rotate = if open() { "rotate-180" } else { "" };
189    let placement_cls = if flip_up() {
190        "bottom-full mb-1.5 origin-bottom"
191    } else {
192        "top-full mt-1.5 origin-top"
193    };
194    let active_descendant = open().then(|| {
195        let idx = active();
196        format!("form-select-{id_prefix}-opt-{idx}")
197    });
198
199    rsx! {
200        div { class: "relative",
201            button {
202                id: "{trigger_id}",
203                r#type: "button",
204                class: "{trigger_cls}",
205                aria_haspopup: "listbox",
206                aria_expanded: "{open()}",
207                aria_activedescendant: active_descendant,
208                onclick: move |_| {
209                    // 打开态下触发器被透明遮罩盖住,点击落在遮罩上即关闭;
210                    // 这里只需处理「未开 → 开」。
211                    if !open() {
212                        #[cfg(target_arch = "wasm32")]
213                        flip_up.set(measure_flip(&trigger_id_click, len));
214                        active.set(selected);
215                        open.set(true);
216                    }
217                },
218                onkeydown: move |e| {
219                    let key = e.key();
220                    let is_space = matches!(&key, Key::Character(s) if s == " ");
221                    if !open() {
222                        if key == Key::ArrowDown || key == Key::ArrowUp || key == Key::Enter
223                            || is_space
224                        {
225                            e.prevent_default();
226                            #[cfg(target_arch = "wasm32")]
227                            flip_up.set(measure_flip(&trigger_id_keys, len));
228                            active.set(selected);
229                            open.set(true);
230                        }
231                        return;
232                    }
233                    if key == Key::ArrowDown {
234                        e.prevent_default();
235                        active.set(wrap_index(active(), 1, len));
236                    } else if key == Key::ArrowUp {
237                        e.prevent_default();
238                        active.set(wrap_index(active(), -1, len));
239                    } else if key == Key::Home { // 不拦截:关闭后焦点自然流转到下一个控件。
240                        e.prevent_default();
241                        active.set(0);
242                    } else if key == Key::End {
243                        e.prevent_default();
244                        active.set(len.saturating_sub(1));
245                    } else if key == Key::Enter || is_space {
246                        e.prevent_default();
247                        if let Some((v, _)) = options_for_keys.get(active()) {
248                            onchange.call(v.clone());
249                        }
250                        open.set(false);
251                    } else if key == Key::Escape {
252                        e.prevent_default();
253                        open.set(false);
254                    } else if key == Key::Tab {
255                        open.set(false);
256                    }
257                },
258                "{selected_label}"
259                // 下拉箭头(打开时翻转)
260                svg {
261                    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}",
262                    view_box: "0 0 24 24",
263                    fill: "none",
264                    stroke: "currentColor",
265                    stroke_width: "2",
266                    path {
267                        stroke_linecap: "round",
268                        stroke_linejoin: "round",
269                        d: "M6 9l6 6 6-6",
270                    }
271                }
272            }
273
274            if open() {
275                // 透明遮罩:拦截外部点击(点遮罩即关),z-40 < 面板 z-50。
276                div {
277                    class: "fixed inset-0 z-40",
278                    onclick: move |_| open.set(false),
279                }
280                ul {
281                    class: "{PANEL_CLASS} {placement_cls}",
282                    role: "listbox",
283                    aria_labelledby: "{trigger_id}",
284                    for (i, opt_value, opt_label, highlight_cls, text_cls) in rows {
285                        li {
286                            id: "form-select-{id_prefix}-opt-{i}",
287                            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}",
288                            role: "option",
289                            aria_selected: "{i == selected}",
290                            // 阻止 mousedown 默认行为:点击选项不夺走触发器焦点。
291                            onmousedown: move |e| e.prevent_default(),
292                            onclick: move |_| {
293                                onchange.call(opt_value.clone());
294                                open.set(false);
295                            },
296                            onmouseenter: move |_| active.set(i),
297                            span { class: "truncate", "{opt_label}" }
298                            if i == selected {
299                                svg {
300                                    class: "w-4 h-4 flex-shrink-0",
301                                    view_box: "0 0 24 24",
302                                    fill: "none",
303                                    stroke: "currentColor",
304                                    stroke_width: "2",
305                                    path {
306                                        stroke_linecap: "round",
307                                        stroke_linejoin: "round",
308                                        d: "M20 6L9 17l-5-5",
309                                    }
310                                }
311                            }
312                        }
313                    }
314                }
315            }
316        }
317    }
318}
319
320/// 表单输入框组件。
321///
322/// Props:
323/// - `id`:input 元素 id,用于与 label 关联
324/// - `r#type`:input 类型(如 `"text"`、`"email"`、`"password"`)
325/// - `placeholder`:占位提示文本
326/// - `value`:当前值
327/// - `disabled`:是否禁用(可选,缺省 `false`)
328/// - `oninput`:输入事件回调,返回新的字符串值
329/// - `onkeydown`:可选的键盘事件回调
330/// - `class`:自定义 class(可选,缺省用 [`INPUT_CLASS`] 全宽表单款)。
331///   内联/固定宽度场景传 [`INPUT_INLINE_CLASS`] 或自定义类串,覆盖默认样式。
332/// - `mono`:是否使用等宽字体(代码片段、表名、JSON 等,缺省 `false`)
333#[component]
334pub fn FormInput(
335    id: Option<String>,
336    r#type: &'static str,
337    placeholder: &'static str,
338    value: String,
339    #[props(default)] disabled: bool,
340    oninput: EventHandler<String>,
341    #[props(default)] onkeydown: Option<EventHandler<KeyboardEvent>>,
342    #[props(default)] class: Option<&'static str>,
343    #[props(default)] mono: bool,
344) -> Element {
345    let base = class.unwrap_or(INPUT_CLASS);
346    let mono_class = if mono { " font-mono" } else { "" };
347    let disabled_class = if disabled {
348        " opacity-60 cursor-not-allowed"
349    } else {
350        ""
351    };
352    rsx! {
353        input {
354            id: id.unwrap_or_default(),
355            class: "{base}{mono_class}{disabled_class}",
356            r#type: "{r#type}",
357            placeholder: "{placeholder}",
358            value: "{value}",
359            disabled,
360            oninput: move |e| oninput.call(e.value()),
361            onkeydown: move |e| {
362                if let Some(ref handler) = onkeydown {
363                    handler.call(e);
364                }
365            },
366        }
367    }
368}
369
370/// 表单标签组件。
371///
372/// Props:
373/// - `label`:标签文本
374/// - `html_for`:关联的 input id
375#[component]
376pub fn FormLabel(label: &'static str, html_for: Option<String>) -> Element {
377    rsx! {
378        label {
379            class: "block text-sm font-medium text-paper-secondary mb-1",
380            r#for: html_for.unwrap_or_default(),
381            "{label}"
382        }
383    }
384}
385
386/// 提示框组件,用于显示成功、错误等状态消息。
387///
388/// Props:
389/// - `message`:提示文本
390/// - `variant`:风格类型,支持 `"error"`、`"success"` 与其他默认类型
391#[component]
392pub fn AlertBox(message: String, variant: &'static str) -> Element {
393    let (bg_class, text_class) = match variant {
394        "error" => (
395            "bg-red-100 dark:bg-red-900/30",
396            "text-red-700 dark:text-red-300",
397        ),
398        "success" => (
399            "bg-green-100 dark:bg-green-900/30",
400            "text-green-700 dark:text-green-300",
401        ),
402        _ => ("bg-paper-code-bg", "text-paper-secondary"),
403    };
404    rsx! {
405        div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", "{message}" }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::{should_flip, wrap_index};
412
413    #[test]
414    fn wrap_index_cycles_both_directions() {
415        assert_eq!(wrap_index(0, 1, 3), 1);
416        assert_eq!(wrap_index(2, 1, 3), 0); // 末尾前进回绕到首
417        assert_eq!(wrap_index(0, -1, 3), 2); // 首位后退回绕到尾
418        assert_eq!(wrap_index(1, -1, 3), 0);
419    }
420
421    #[test]
422    fn wrap_index_empty_is_zero() {
423        assert_eq!(wrap_index(5, 1, 0), 0); // 空列表不越界
424    }
425
426    #[test]
427    fn should_flip_only_when_below_insufficient_and_above_wider() {
428        // 下方充足:不翻(below = 800-140 = 660 > 200+14)
429        assert!(!should_flip(100.0, 140.0, 800.0, 200.0));
430        // 下方不足且上方更宽:上翻(below = 160 < 214,above = 600 > 160)
431        assert!(should_flip(600.0, 640.0, 800.0, 200.0));
432        // 下方不足但上方更窄:保持向下(above = 30 < below = 190)
433        assert!(!should_flip(30.0, 70.0, 260.0, 200.0));
434        // 恰好放得下(below = 214 == 200+14,非严格小于):不翻
435        assert!(!should_flip(300.0, 340.0, 554.0, 200.0));
436    }
437}