feat(admin): FormSelect 自定义主题化下拉弹层,替代原生 select 弹窗

原生 <select> 的弹出列表由 OS/浏览器渲染,暗色主题下是白底系统菜单,
无法跟随 Catppuccin 主题。将 FormSelect 内部重写为
button[aria-haspopup=listbox] + 绝对定位面板(组件签名不变,调用方零改动):

- 面板/选项全部使用语义色 token,与 POPOVER_PANEL_CLASS 同源
  (卡片化圆角 + 阴影),新增 select-enter 入场动画(150ms,
  含 prefers-reduced-motion 豁免)
- 键盘:focus 留在触发器,aria-activedescendant 高亮;↑↓ 循环
  (wrap_index)、Enter/Space 选中、Esc 关闭、Home/End 跳首尾、
  Tab 关闭并自然流转焦点;选项 onmousedown 阻止默认行为避免抢夺焦点
- 透明遮罩拦截外部点击关闭(同 Popover 模式)
- 打开时按视口空间决定展开方向(should_flip + measure_flip,
  下翻/上翻分别设 transform-origin)
- 打开/键盘导航时高亮项 scrollIntoView(block: nearest)
- 选中行:text-paper-accent + check 图标;高亮行:accent-soft 背景

should_flip / wrap_index 抽为纯函数并配单测(边界:恰好放得下、
上方更窄不翻、空列表回绕安全)。
This commit is contained in:
xfy 2026-07-28 14:19:52 +08:00
parent 8b4a2b674e
commit 717d17d5a7
2 changed files with 302 additions and 36 deletions

View File

@ -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;
}

View File

@ -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);
/// 面板应向上展开的条件:视口下方空间不足,且上方空间比下方更宽余。
///
/// 原生 `<select>` 的默认箭头由各浏览器自行绘制,在深色主题与圆角样式下
/// 位置/颜色不协调(无法垂直居中、无法跟随主题色),故用 `appearance-none`
/// 隐藏后叠一个 `top-1/2 -translate-y-1/2` 垂直居中的 chevron SVG
/// `pointer-events-none`,点击穿透到 select
/// 纯函数便于单测;`panel_height` 由调用方按选项数估算(见 `measure_flip`)。
#[allow(dead_code)] // server 构建下仅被 dead 的组件体引用wasm 与单测为真实调用方)
fn should_flip(trigger_top: f64, trigger_bottom: f64, viewport_height: f64, panel_height: f64) -> bool {
/// 面板与触发器的间隙mt-1.5)加视口边缘留白。
const MARGIN: f64 = 14.0;
let below = viewport_height - trigger_bottom;
let above = trigger_top;
below < panel_height + MARGIN && above > below
}
/// 键盘导航的循环索引:在 `len` 个选项中从 `cur` 移动 `delta`±1越界回绕。
#[allow(dead_code)] // 同 should_flip
fn wrap_index(cur: usize, delta: i32, len: usize) -> usize {
if len == 0 {
return 0;
}
(cur as i32 + delta).rem_euclid(len as i32) as usize
}
/// 测量触发器视口位置,决定面板展开方向(仅 wasmSSR 无 DOM
#[cfg(target_arch = "wasm32")]
fn measure_flip(trigger_id: &str, option_count: usize) -> bool {
/// 选项行高24px 行盒 + py-2.520px 垂直内边距)。
const ROW_HEIGHT: f64 = 44.0;
/// 面板 1px 边框 ×2 + p-1.5 内边距。
const PANEL_CHROME: f64 = 14.0;
/// 面板高度上限max-h-60240px+ PANEL_CHROME。
const PANEL_MAX: f64 = 254.0;
let Some(window) = web_sys::window() else {
return false;
};
let Some(document) = window.document() else {
return false;
};
let Some(el) = document.get_element_by_id(trigger_id) else {
return false;
};
let rect = el.get_bounding_client_rect();
let viewport = window
.inner_height()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(800.0);
let panel_height = ((option_count as f64) * ROW_HEIGHT + PANEL_CHROME).min(PANEL_MAX);
should_flip(rect.top(), rect.bottom(), viewport, panel_height)
}
/// 把指定选项滚入面板可视区(打开/键盘导航时跟随;仅 wasm
#[cfg(target_arch = "wasm32")]
fn scroll_option_into_view(element_id: &str) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let Some(el) = document.get_element_by_id(element_id) else {
return;
};
let opts = web_sys::ScrollIntoViewOptions::new();
opts.set_block(web_sys::ScrollLogicalPosition::Nearest);
el.scroll_into_view_with_scroll_into_view_options(&opts);
}
/// 下拉选择框组件(自定义弹层,全主题化)。
///
/// 泛型值绑定:`<option>` 的 value 内部用序号占位,`onchange` 直接回传选中
/// 项的 `T`,调用方无需做「字符串反查枚举」的样板代码。
/// 原生 `<select>` 的弹出列表由 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<T: Clone + PartialEq + 'static>(
options: Vec<(T, &'static str)>,
onchange: EventHandler<T>,
) -> 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::<usize>() {
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 < 214above = 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));
}
}