Skip to main content

yggdrasil/components/ui/
menu.rs

1//! 操作菜单:「⋯」等按钮触发的一列操作。
2use super::controls::{button_class, ButtonSize, ButtonVariant};
3use super::display::Tooltip;
4use super::icons::{Icon, IconName};
5use leptos::ev::KeyboardEvent;
6use leptos::prelude::*;
7
8static MENU_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9
10/// 菜单里的一项。`id` 回传给 `on_select`,用于区分点了哪一项。
11#[derive(Clone, Debug, PartialEq)]
12pub struct MenuItem {
13    pub id: &'static str,
14    pub label: String,
15    pub icon: Option<IconName>,
16    /// 红色语气:删除、移入回收站等。
17    pub danger: bool,
18    /// 不可用时变淡、跳过键盘移动,并以悬浮提示写明原因。
19    pub disabled: Option<&'static str>,
20    pub shortcut: Option<&'static str>,
21    /// 在本项之前画一条分隔线。
22    pub separated: bool,
23}
24
25impl MenuItem {
26    pub fn new(id: &'static str, label: impl Into<String>) -> Self {
27        Self {
28            id,
29            label: label.into(),
30            icon: None,
31            danger: false,
32            disabled: None,
33            shortcut: None,
34            separated: false,
35        }
36    }
37
38    pub fn icon(mut self, icon: IconName) -> Self {
39        self.icon = Some(icon);
40        self
41    }
42
43    pub fn danger(mut self) -> Self {
44        self.danger = true;
45        self
46    }
47
48    #[allow(dead_code)] // 随页面重写逐步启用
49    pub fn disabled(mut self, reason: &'static str) -> Self {
50        self.disabled = Some(reason);
51        self
52    }
53
54    #[allow(dead_code)] // 随页面重写逐步启用
55    pub fn shortcut(mut self, keys: &'static str) -> Self {
56        self.shortcut = Some(keys);
57        self
58    }
59
60    pub fn separated(mut self) -> Self {
61        self.separated = true;
62        self
63    }
64}
65
66/// 在可用项之间移动焦点的目标序号;当前项不可用或不在列表时落到首项或末项。
67fn step_enabled(items: &[MenuItem], cur: Option<usize>, delta: i32) -> Option<usize> {
68    let enabled: Vec<usize> = items
69        .iter()
70        .enumerate()
71        .filter(|(_, item)| item.disabled.is_none())
72        .map(|(i, _)| i)
73        .collect();
74    if enabled.is_empty() {
75        return None;
76    }
77    let len = enabled.len() as i32;
78    match cur.and_then(|c| enabled.iter().position(|&i| i == c)) {
79        Some(pos) => Some(enabled[(pos as i32 + delta).rem_euclid(len) as usize]),
80        None if delta < 0 => enabled.last().copied(),
81        None => enabled.first().copied(),
82    }
83}
84
85/// 按 id 聚焦元素(仅 wasm)。
86#[cfg(target_arch = "wasm32")]
87fn focus_by_id(element_id: &str) {
88    use wasm_bindgen::JsCast;
89    if let Some(el) = web_sys::window()
90        .and_then(|w| w.document())
91        .and_then(|d| d.get_element_by_id(element_id))
92        .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
93    {
94        let _ = el.focus();
95    }
96}
97
98/// 测量触发器视口位置,返回 fixed 面板的内联定位样式与实际展开方向(仅 wasm)。
99///
100/// 菜单挂在 `overflow: clip` 的表格面板里时,绝对定位会被裁掉,故面板用 fixed,
101/// 锚点取触发器矩形;默认向下的菜单在下方放不下时翻到上方。
102#[cfg(target_arch = "wasm32")]
103fn measure_panel(
104    trigger_id: &str,
105    item_count: usize,
106    placement: &'static str,
107    align: &'static str,
108) -> Option<(String, &'static str)> {
109    let document = web_sys::window()?.document()?;
110    let rect = document
111        .get_element_by_id(trigger_id)?
112        .get_bounding_client_rect();
113    let root = document.document_element()?;
114    let (width, height) = (root.client_width() as f64, root.client_height() as f64);
115    let placement = if placement == "bottom"
116        && crate::components::forms::measure_flip(trigger_id, item_count)
117    {
118        "top"
119    } else {
120        placement
121    };
122    let vertical = if placement == "top" {
123        format!("bottom: {}px;", height - rect.top() + 6.0)
124    } else {
125        format!("top: {}px;", rect.bottom() + 6.0)
126    };
127    let horizontal = if align == "start" {
128        format!("left: {}px;", rect.left())
129    } else {
130        format!("right: {}px;", width - rect.right())
131    };
132    Some((format!("{vertical} {horizontal}"), placement))
133}
134
135/// 图标按钮触发的操作菜单。
136///
137/// - 打开后焦点落在第一个可用项;↑/↓ 在可用项间循环(roving focus),Home/End 跳首尾;
138/// - Enter/Space 选中并关闭,Esc 关闭,关闭后焦点回到触发按钮;Tab 关闭并自然流转;
139/// - 点菜单外部关闭;不可用项变淡、写明原因,键盘与点击都跳过。
140///
141/// 触发器与菜单项的 `id`(`menu-N-trigger` 等)在挂载后由客户端计数分配:全局计数器在服务端是
142/// 进程级的,直接写进 SSR HTML 会与水合后的客户端计数不一致。
143///
144/// Props:
145/// - `label`:触发按钮的读屏名称与悬浮提示,如「更多操作」
146/// - `icon`:触发按钮图标,缺省 `Ellipsis`
147/// - `items`:菜单项(`Vec<MenuItem>` 或返回它的闭包 / 信号)
148/// - `on_select`:选中某项时回传其 `id`
149/// - `placement`:`"bottom"`(默认)/ `"top"`;`align`:`"end"`(默认,右对齐)/ `"start"`
150#[component]
151pub fn Menu(
152    #[prop(into)] label: String,
153    #[prop(into)] items: Signal<Vec<MenuItem>>,
154    #[prop(into)] on_select: Callback<&'static str>,
155    #[prop(default = IconName::Ellipsis)] icon: IconName,
156    #[prop(default = ButtonSize::Sm)] size: ButtonSize,
157    #[prop(default = "bottom".to_string(), into)] placement: String,
158    #[prop(default = "end".to_string(), into)] align: String,
159) -> impl IntoView {
160    // 挂载后才分配序号,保证 SSR HTML 与水合一致(见组件文档)。
161    let menu_id = RwSignal::new(None::<usize>);
162    Effect::new(move |_| {
163        if menu_id.get_untracked().is_none() {
164            menu_id.set(Some(
165                MENU_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
166            ));
167        }
168    });
169    let trigger_id = move || menu_id.get().map(|n| format!("menu-{n}-trigger"));
170    let panel_id = move || menu_id.get().map(|n| format!("menu-{n}"));
171    let item_dom_id = move |i: usize| {
172        format!(
173            "menu-{}-item-{i}",
174            menu_id.get_untracked().unwrap_or_default()
175        )
176    };
177
178    let open = RwSignal::new(false);
179    // 打开时测得的 fixed 定位与实际方向;SSR 与首帧回退到声明的 placement。
180    let panel_pos = RwSignal::new(None::<(String, &'static str)>);
181    let focused = RwSignal::new(None::<usize>);
182
183    // 打开或键盘移动后,把焦点放到当前项上。
184    Effect::new(move |_| {
185        if let (true, Some(_i)) = (open.get(), focused.get()) {
186            #[cfg(target_arch = "wasm32")]
187            focus_by_id(&item_dom_id(_i));
188        }
189    });
190
191    let close = move |return_focus: bool| {
192        open.set(false);
193        focused.set(None);
194        panel_pos.set(None);
195        #[cfg(target_arch = "wasm32")]
196        if return_focus {
197            if let Some(id) = trigger_id() {
198                focus_by_id(&id);
199            }
200        }
201        #[cfg(not(target_arch = "wasm32"))]
202        let _ = return_focus;
203    };
204
205    let placement: &'static str = if placement == "top" { "top" } else { "bottom" };
206    let align: &'static str = if align == "start" { "start" } else { "end" };
207    // 展开前测量触发器矩形,得到 fixed 面板的定位(仅 wasm;SSR 无 DOM)。
208    let measure = move || {
209        #[cfg(target_arch = "wasm32")]
210        if let Some(id) = trigger_id() {
211            let item_count = items.with_untracked(|items| items.len());
212            panel_pos.set(measure_panel(&id, item_count, placement, align));
213        }
214    };
215    let actual_placement = move || {
216        panel_pos
217            .with(|pos| pos.as_ref().map(|(_, actual)| *actual))
218            .unwrap_or(placement)
219    };
220    let panel_style = move || panel_pos.with(|pos| pos.as_ref().map(|(style, _)| style.clone()));
221    // 提示与菜单分处触发器两侧,互不遮挡。
222    let tooltip_placement = Signal::derive(move || {
223        if actual_placement() == "top" {
224            "bottom"
225        } else {
226            "top"
227        }
228        .to_string()
229    });
230    let trigger_class = button_class(ButtonVariant::Ghost, size);
231    let icon_px = if size == ButtonSize::Xs { 14 } else { 16 };
232
233    let open_with = move |delta: i32| {
234        measure();
235        open.set(true);
236        focused.set(items.with_untracked(|items| step_enabled(items, None, delta)));
237    };
238
239    view! {
240        <span class="menu-wrap">
241            <Tooltip tip=label.clone() placement=tooltip_placement>
242                <button
243                    id=trigger_id
244                    class=format!("{trigger_class} btn-icon")
245                    type="button"
246                    aria-label=label
247                    aria-haspopup="menu"
248                    aria-expanded=move || open.get().to_string()
249                    aria-controls=move || if open.get() { panel_id() } else { None }
250                    on:click=move |_| {
251                        if open.get_untracked() {
252                            close(false);
253                        } else {
254                            open_with(1);
255                        }
256                    }
257                    on:keydown=move |e: KeyboardEvent| {
258                        if open.get_untracked() {
259                            return;
260                        }
261                        match e.key().as_str() {
262                            "ArrowDown" => {
263                                e.prevent_default();
264                                open_with(1);
265                            }
266                            "ArrowUp" => {
267                                e.prevent_default();
268                                open_with(-1);
269                            }
270                            _ => {}
271                        }
272                    }
273                >
274                    <Icon name=icon size=icon_px/>
275                </button>
276            </Tooltip>
277            {move || {
278                open.get()
279                    .then(|| {
280                        view! {
281                            <div
282                                class="fixed inset-0 z-40"
283                                on:click=move |_| close(false)
284                                // fixed 面板不随页面滚动,滚轮一动就收起,避免与触发器脱节。
285                                on:wheel=move |_| close(false)
286                            ></div>
287                            <div
288                                id=panel_id
289                                class="menu"
290                                role="menu"
291                                aria-labelledby=trigger_id
292                                data-placement=actual_placement
293                                data-align=align
294                                style=panel_style
295                                on:keydown=move |e: KeyboardEvent| {
296                                    let step = |from: Option<usize>, delta: i32| {
297                                        items.with_untracked(|items| step_enabled(items, from, delta))
298                                    };
299                                    match e.key().as_str() {
300                                        "ArrowDown" => {
301                                            e.prevent_default();
302                                            focused.set(step(focused.get_untracked(), 1));
303                                        }
304                                        "ArrowUp" => {
305                                            e.prevent_default();
306                                            focused.set(step(focused.get_untracked(), -1));
307                                        }
308                                        "Home" => {
309                                            e.prevent_default();
310                                            focused.set(step(None, 1));
311                                        }
312                                        "End" => {
313                                            e.prevent_default();
314                                            focused.set(step(None, -1));
315                                        }
316                                        "Escape" => {
317                                            e.prevent_default();
318                                            close(true);
319                                        }
320                                        "Tab" => close(false),
321                                        _ => {}
322                                    }
323                                }
324                            >
325                                {move || {
326                                    items
327                                        .get()
328                                        .into_iter()
329                                        .enumerate()
330                                        .map(|(i, item)| {
331                                            view! {
332                                                <MenuRow
333                                                    id=item_dom_id(i)
334                                                    item=item
335                                                    focused=Signal::derive(move || focused.get() == Some(i))
336                                                    on_hover=Callback::new(move |()| focused.set(Some(i)))
337                                                    on_pick=Callback::new(move |id: &'static str| {
338                                                        close(true);
339                                                        on_select.run(id);
340                                                    })
341                                                />
342                                            }
343                                        })
344                                        .collect_view()
345                                }}
346                            </div>
347                        }
348                    })
349            }}
350        </span>
351    }
352}
353
354/// 菜单中的一项(可带前置分隔线)。
355#[component]
356fn MenuRow(
357    id: String,
358    item: MenuItem,
359    #[prop(into)] focused: Signal<bool>,
360    #[prop(into)] on_hover: Callback<()>,
361    #[prop(into)] on_pick: Callback<&'static str>,
362) -> impl IntoView {
363    let enabled = item.disabled.is_none();
364    let reason = item.disabled.unwrap_or_default();
365    let item_id = item.id;
366    view! {
367        {item.separated.then(|| view! { <div class="menu-separator" role="separator"></div> })}
368        <button
369            id=id
370            class="menu-item"
371            type="button"
372            role="menuitem"
373            tabindex=move || if focused.get() { "0" } else { "-1" }
374            data-tone=if item.danger { "danger" } else { "" }
375            aria-disabled=(!enabled).then_some("true")
376            title=reason
377            on:mouseenter=move |_| {
378                if enabled {
379                    on_hover.run(());
380                }
381            }
382            on:click=move |_| {
383                if enabled {
384                    on_pick.run(item_id);
385                }
386            }
387        >
388            {item.icon.map(|icon| view! { <Icon name=icon size=15/> })}
389            <span>{item.label}</span>
390            {item.shortcut.map(|keys| view! { <kbd>{keys}</kbd> })}
391        </button>
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    fn items() -> Vec<MenuItem> {
400        vec![
401            MenuItem::new("edit", "编辑"),
402            MenuItem::new("archive", "归档").disabled("已归档"),
403            MenuItem::new("copy", "复制链接"),
404            MenuItem::new("trash", "移入回收站").danger().separated(),
405        ]
406    }
407
408    #[test]
409    fn step_enabled_skips_disabled_items_and_wraps() {
410        let items = items();
411        assert_eq!(step_enabled(&items, None, 1), Some(0));
412        assert_eq!(step_enabled(&items, None, -1), Some(3));
413        assert_eq!(step_enabled(&items, Some(0), 1), Some(2)); // 跳过不可用的「归档」
414        assert_eq!(step_enabled(&items, Some(3), 1), Some(0)); // 末尾回绕
415        assert_eq!(step_enabled(&items, Some(0), -1), Some(3)); // 首位回绕
416        assert_eq!(step_enabled(&items, Some(1), 1), Some(0)); // 当前项不可用时从首项起
417    }
418
419    #[test]
420    fn step_enabled_handles_all_disabled() {
421        let items = vec![MenuItem::new("a", "A").disabled("不可用")];
422        assert_eq!(step_enabled(&items, None, 1), None);
423    }
424
425    #[component]
426    fn Harness() -> impl IntoView {
427        view! { <Menu label="更多操作" items=items() on_select=|_| {}/> }
428    }
429
430    #[test]
431    fn closed_menu_renders_only_the_trigger() {
432        let html = Owner::new().with(|| view! { <Harness/> }.to_html());
433        assert!(html.contains("aria-haspopup=\"menu\""), "{html}");
434        assert!(html.contains("aria-expanded=\"false\""), "{html}");
435        assert!(!html.contains("role=\"menu\""), "{html}");
436    }
437}