diff --git a/input.css b/input.css index 171c796..0644b82 100644 --- a/input.css +++ b/input.css @@ -83,6 +83,22 @@ animation: popover-enter 150ms cubic-bezier(0.22, 1, 0.36, 1) both; } + /* 下拉选择面板入场(仅垂直微移,无水平居中变换;展开方向由 transform-origin 控制) */ + @keyframes select-enter { + 0% { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + 100% { + opacity: 1; + transform: none; + } + } + + .animate-select-enter { + animation: select-enter 150ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + /* 列表行进场(比 page-enter 更轻的位移,供 admin 列表逐行 stagger 使用, 配合内联 style="animation-delay: Nms" 与 both fill-mode 实现阶梯入场) */ @keyframes row-enter { @@ -1100,6 +1116,7 @@ .page-enter, .animate-page-enter, .animate-popover-enter, + .animate-select-enter, .animate-row-enter { animation: none; } diff --git a/src/components/forms.rs b/src/components/forms.rs index e3343eb..8347692 100644 --- a/src/components/forms.rs +++ b/src/components/forms.rs @@ -10,18 +10,88 @@ pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border round /// 主按钮 CSS 类,用于表单提交等主操作按钮。 pub 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"; -/// 下拉选择框组件(自定义 chevron,跨浏览器对齐一致)。 +/// FormSelect 实例 id 计数器(跨泛型单例化全局唯一)。 +#[allow(dead_code)] // server 构建下仅被 dead 的 FormSelect 组件体引用 +static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// 面板应向上展开的条件:视口下方空间不足,且上方空间比下方更宽余。 /// -/// 原生 `` 的弹出列表由 OS/浏览器渲染,无法跟随主题(暗色下是白底 +/// 系统菜单),故用 `button[aria-haspopup=listbox]` + 绝对定位面板重写: +/// - 面板/选项全部使用 Catppuccin 语义色,带 `select-enter` 入场动画; +/// - focus 始终留在触发器,键盘经 `aria-activedescendant` 高亮(↑↓ 循环、 +/// Enter/Space 选中、Esc 关闭、Home/End 跳首尾、Tab 关闭并自然流转焦点); +/// - 透明遮罩拦截外部点击关闭(同 Popover 模式);选项 `onmousedown` 阻止默认 +/// 行为,点击不夺走触发器焦点; +/// - 打开时视口下方空间不足且上方更宽余则向上展开(`should_flip`); +/// - 泛型值绑定:`onchange` 直接回传选中项的 `T`,无需字符串反查。 /// /// Props: -/// - `id`:select 元素 id,用于与 label 关联 +/// - `id`:触发器 id,用于与 label 关联(缺省用内部计数器生成) /// - `value`:当前选中项(受控) /// - `options`:可选项 `(值, 标签)` 列表 /// - `onchange`:选中变化回调,回传新选中项的值 @@ -32,43 +102,192 @@ pub fn FormSelect( options: Vec<(T, &'static str)>, onchange: EventHandler, ) -> Element { - // 与 INPUT_CLASS 同族,但隐藏原生箭头(appearance-none), - // 右侧预留 pr-10 空间给自定义 chevron。定义在函数体内:当前仅 wasm 端 - // 后台页面使用本组件,模块级常量在 server 构建下会触发 dead_code。 - const SELECT_CLASS: &str = "w-full appearance-none cursor-pointer truncate 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"; + // 触发器样式与 INPUT_CLASS 同族(右侧 pr-10 预留 chevron 位);面板与 + // POPOVER_PANEL_CLASS 同源(卡片化圆角 + 阴影)。定义在函数体内:当前仅 + // wasm 端后台页面使用本组件,模块级常量在 server 构建下会触发 dead_code。 + 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"; + const PANEL_CLASS: &str = "absolute inset-x-0 z-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"; + + let id_prefix = use_hook(|| FORM_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst)); // 受控选中序号;value 不在 options 时兜底 0(与浏览器默认选中第一项一致)。 let selected = options.iter().position(|(v, _)| *v == value).unwrap_or(0); - // onchange 闭包与下方 for 循环各需一份 options(序号 → 值回查 / 渲染)。 - let options_for_change = options.clone(); + let selected_label = options.get(selected).map(|(_, l)| *l).unwrap_or_default(); + let len = options.len(); + + let mut open = use_signal(|| false); + let mut active = use_signal(|| selected); + // flip_up 的 set 均在 wasm 门控语句内(宿主构建下只读),参照 FilterTabs 先例。 + #[allow(unused_mut)] + let mut flip_up = use_signal(|| false); + + // onkeydown 闭包与下方选项渲染各需一份 options(键盘选中回查 / 渲染)。 + let options_for_keys = options.clone(); + + // 触发器 id:外部未指定时用内部前缀生成,ARIA 关联统一走它。 + let trigger_id = id.unwrap_or_else(|| format!("form-select-{id_prefix}")); + // 两个事件闭包各持一份(仅 wasm 用于 flip 测量按 id 查元素)。 + #[cfg(target_arch = "wasm32")] + let trigger_id_click = trigger_id.clone(); + #[cfg(target_arch = "wasm32")] + let trigger_id_keys = trigger_id.clone(); + + // 打开或键盘导航时,把高亮项滚入面板可视区。 + use_effect(move || { + if open() { + #[cfg(target_arch = "wasm32")] + { + let idx = active(); + scroll_option_into_view(&format!("form-select-{id_prefix}-opt-{idx}")); + } + } + }); + + // 预计算每行展示态,rsx 循环内只做移动与闭包捕获。 + let active_idx = active(); + let rows: Vec<(usize, T, &'static str, &'static str, &'static str)> = options + .iter() + .enumerate() + .map(|(i, (v, l))| { + let highlight = if i == active_idx { + "bg-[var(--color-paper-accent-soft)]" + } else { + "" + }; + let text = if i == selected { + "text-paper-accent" + } else { + "text-[var(--color-paper-primary)]" + }; + (i, v.clone(), *l, highlight, text) + }) + .collect(); + + let chevron_rotate = if open() { "rotate-180" } else { "" }; + let placement_cls = if flip_up() { + "bottom-full mb-1.5 origin-bottom" + } else { + "top-full mt-1.5 origin-top" + }; + let active_descendant = open().then(|| { + let idx = active(); + format!("form-select-{id_prefix}-opt-{idx}") + }); + rsx! { div { class: "relative", - select { - id: id.unwrap_or_default(), - class: "{SELECT_CLASS}", - value: "{selected}", - onchange: move |e| { - if let Ok(i) = e.value().parse::() { - if let Some((v, _)) = options_for_change.get(i) { - onchange.call(v.clone()); - } + button { + id: "{trigger_id}", + r#type: "button", + class: "{TRIGGER_CLASS}", + aria_haspopup: "listbox", + aria_expanded: "{open()}", + aria_activedescendant: active_descendant, + onclick: move |_| { + // 打开态下触发器被透明遮罩盖住,点击落在遮罩上即关闭; + // 这里只需处理「未开 → 开」。 + if !open() { + #[cfg(target_arch = "wasm32")] + flip_up.set(measure_flip(&trigger_id_click, len)); + active.set(selected); + open.set(true); } }, - for (i, (_, option_label)) in options.iter().enumerate() { - option { value: "{i}", selected: i == selected, "{option_label}" } + onkeydown: move |e| { + let key = e.key(); + let is_space = matches!(&key, Key::Character(s) if s == " "); + if !open() { + if key == Key::ArrowDown || key == Key::ArrowUp || key == Key::Enter || is_space { + e.prevent_default(); + #[cfg(target_arch = "wasm32")] + flip_up.set(measure_flip(&trigger_id_keys, len)); + active.set(selected); + open.set(true); + } + return; + } + if key == Key::ArrowDown { + e.prevent_default(); + active.set(wrap_index(active(), 1, len)); + } else if key == Key::ArrowUp { + e.prevent_default(); + active.set(wrap_index(active(), -1, len)); + } else if key == Key::Home { + e.prevent_default(); + active.set(0); + } else if key == Key::End { + e.prevent_default(); + active.set(len.saturating_sub(1)); + } else if key == Key::Enter || is_space { + e.prevent_default(); + if let Some((v, _)) = options_for_keys.get(active()) { + onchange.call(v.clone()); + } + open.set(false); + } else if key == Key::Escape { + e.prevent_default(); + open.set(false); + } else if key == Key::Tab { + // 不拦截:关闭后焦点自然流转到下一个控件。 + open.set(false); + } + }, + "{selected_label}" + // 下拉箭头(打开时翻转) + svg { + 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}", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M6 9l6 6 6-6", + } } } - // 自定义下拉箭头(替代原生箭头,保证垂直居中 + 跟随主题色) - svg { - class: "pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-paper-secondary", - view_box: "0 0 24 24", - fill: "none", - stroke: "currentColor", - stroke_width: "2", - path { - stroke_linecap: "round", - stroke_linejoin: "round", - d: "M6 9l6 6 6-6", + + if open() { + // 透明遮罩:拦截外部点击(点遮罩即关),z-40 < 面板 z-50。 + div { + class: "fixed inset-0 z-40", + onclick: move |_| open.set(false), + } + ul { + class: "{PANEL_CLASS} {placement_cls}", + role: "listbox", + aria_labelledby: "{trigger_id}", + for (i, opt_value, opt_label, highlight_cls, text_cls) in rows { + li { + id: "form-select-{id_prefix}-opt-{i}", + 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}", + role: "option", + aria_selected: "{i == selected}", + // 阻止 mousedown 默认行为:点击选项不夺走触发器焦点。 + onmousedown: move |e| e.prevent_default(), + onclick: move |_| { + onchange.call(opt_value.clone()); + open.set(false); + }, + onmouseenter: move |_| active.set(i), + span { class: "truncate", "{opt_label}" } + if i == selected { + svg { + class: "w-4 h-4 flex-shrink-0", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M20 6L9 17l-5-5", + } + } + } + } + } } } } @@ -156,3 +375,33 @@ pub fn AlertBox(message: String, variant: &'static str) -> Element { div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", "{message}" } } } + +#[cfg(test)] +mod tests { + use super::{should_flip, wrap_index}; + + #[test] + fn wrap_index_cycles_both_directions() { + assert_eq!(wrap_index(0, 1, 3), 1); + assert_eq!(wrap_index(2, 1, 3), 0); // 末尾前进回绕到首 + assert_eq!(wrap_index(0, -1, 3), 2); // 首位后退回绕到尾 + assert_eq!(wrap_index(1, -1, 3), 0); + } + + #[test] + fn wrap_index_empty_is_zero() { + assert_eq!(wrap_index(5, 1, 0), 0); // 空列表不越界 + } + + #[test] + fn should_flip_only_when_below_insufficient_and_above_wider() { + // 下方充足:不翻(below = 800-140 = 660 > 200+14) + assert!(!should_flip(100.0, 140.0, 800.0, 200.0)); + // 下方不足且上方更宽:上翻(below = 160 < 214,above = 600 > 160) + assert!(should_flip(600.0, 640.0, 800.0, 200.0)); + // 下方不足但上方更窄:保持向下(above = 30 < below = 190) + assert!(!should_flip(30.0, 70.0, 260.0, 200.0)); + // 恰好放得下(below = 214 == 200+14,非严格小于):不翻 + assert!(!should_flip(300.0, 340.0, 554.0, 200.0)); + } +}