Skip to main content

fc_dioxus/components/
menu.rs

1//! Menus, menu buttons, and context menus, from menu.css, on upstream's
2//! `fc-context-menu` model.
3//!
4//! Follows upstream's `menu.ts`: the menu is a popover in the top layer with light
5//! dismiss; focus moves into it (roving, unlike a select) and always returns to its
6//! invoker on close, Escape included; arrows wrap past separators and unavailable items,
7//! Home and End jump, typed characters search within 500 ms; Tab closes without
8//! trapping. Traversal is `fc_context_menu::Menu`, which upstream can run as WASM.
9use crate::{
10    core::{
11        assets,
12        attrs::{component_attributes, open_attribute},
13        platform::{DomBridge, MenuPoint},
14    },
15    models::context_menu as model,
16    prelude::*,
17    theme::use_stylesheets,
18};
19const BEM: Bem = Bem::new("fc-menu");
20const ITEM: Bem = Bem::new("fc-menu__item");
21fc_modifier! { enum ItemTone for ITEM { Normal => None, Danger => "danger" } }
22
23/// One line of a menu.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum MenuEntry {
26    /// An action.
27    Item {
28        /// Reported to `onselect`.
29        id: String,
30        /// What people read; typeahead matches it.
31        label: String,
32        /// A keyboard shortcut shown at the end, e.g. "⌘X". Decorative.
33        shortcut: Option<String>,
34        /// A destructive action.
35        danger: bool,
36        /// Unavailable: skipped by arrows, still announced.
37        disabled: bool,
38    },
39    /// A setting toggled from the menu.
40    Checkbox {
41        /// Reported to `onselect`.
42        id: String,
43        /// What people read.
44        label: String,
45        /// Current state.
46        checked: bool,
47        /// Unavailable.
48        disabled: bool,
49    },
50    /// An item that leads to another menu; `onselect` receives its id to open it.
51    Submenu {
52        /// Reported to `onselect`.
53        id: String,
54        /// What people read.
55        label: String,
56    },
57    /// A heading over the items after it.
58    Group(String),
59    /// A rule between groups.
60    Separator,
61}
62impl MenuEntry {
63    /// A plain action.
64    #[must_use]
65    pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
66        Self::Item {
67            id: id.into(),
68            label: label.into(),
69            shortcut: None,
70            danger: false,
71            disabled: false,
72        }
73    }
74    fn modeled(&self) -> Option<model::MenuItem> {
75        let (id, label, kind, disabled, checked, shortcut) = match self {
76            Self::Item {
77                id,
78                label,
79                shortcut,
80                disabled,
81                ..
82            } => (
83                id,
84                label,
85                model::ItemKind::Normal,
86                *disabled,
87                false,
88                shortcut.clone(),
89            ),
90            Self::Checkbox {
91                id,
92                label,
93                checked,
94                disabled,
95            } => (
96                id,
97                label,
98                model::ItemKind::Checkbox,
99                *disabled,
100                *checked,
101                None,
102            ),
103            Self::Submenu { id, label } => {
104                (id, label, model::ItemKind::Submenu, false, false, None)
105            }
106            Self::Group(_) | Self::Separator => return None,
107        };
108        Some(model::MenuItem {
109            id: id.clone(),
110            label: label.clone(),
111            kind,
112            disabled,
113            checked,
114            shortcut,
115        })
116    }
117}
118
119/// Where a menu opens.
120#[derive(Debug, Clone, PartialEq)]
121pub enum MenuAnchor {
122    /// Under a trigger, at its start edge, flipping above when there is no room.
123    Below(Id),
124    /// At a viewport point, e.g. a right-click.
125    At(MenuPoint),
126}
127
128#[derive(Clone, Copy)]
129struct MenuState {
130    model: Signal<Option<model::Menu>>,
131    pending_focus: Signal<bool>,
132    typing: Signal<u64>,
133    expecting_open: Signal<bool>,
134    /// Whether the last effect saw it open, so only a real close moves focus back.
135    was_open: Signal<bool>,
136    /// Set when Tab closed the menu. The browser has already moved focus to the next
137    /// stop, and returning it to the trigger afterwards would send the user backwards.
138    leaving: Signal<bool>,
139}
140
141/// A menu of actions, shown while `open`. Focus moves to the first item when it opens and
142/// back to `return_focus` when it closes.
143#[component]
144pub fn Menu(
145    label: AccessibleName,
146    entries: Vec<MenuEntry>,
147    open: bool,
148    anchor: MenuAnchor,
149    /// Where focus goes back to on close: the trigger, or the element right-clicked.
150    return_focus: Id,
151    onclose: EventHandler<()>,
152    onselect: EventHandler<String>,
153    /// Open with the last item focused, for a menu button opened by Arrow Up.
154    #[props(default)]
155    from_end: bool,
156    /// The menu element's id, when a trigger refers to it. Generated when omitted.
157    #[props(default)]
158    menu_id: Option<Id>,
159    #[props(default)] class: Option<String>,
160    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
161) -> Element {
162    let generated = use_id();
163    let id = menu_id.unwrap_or(generated);
164    let built = model::Menu::new(entries.iter().filter_map(MenuEntry::modeled).collect()).ok();
165    let mut state = use_hook(|| MenuState {
166        model: Signal::new(None),
167        pending_focus: Signal::new(false),
168        typing: Signal::new(0),
169        expecting_open: Signal::new(false),
170        was_open: Signal::new(false),
171        leaving: Signal::new(false),
172    });
173    let effect_id = id.clone();
174    let effect_anchor = anchor.clone();
175    let effect_return = return_focus.clone();
176    use_effect(use_reactive!(|open, from_end| {
177        let (id, anchor, back) = (
178            effect_id.clone(),
179            effect_anchor.clone(),
180            effect_return.clone(),
181        );
182        let closing = !open && *state.was_open.peek();
183        state.was_open.set(open);
184        if open {
185            state.expecting_open.set(true);
186            spawn(async move {
187                let _ = DomBridge.toggle_popover(&id, true).await;
188                let point = match anchor {
189                    MenuAnchor::At(point) => Ok(point),
190                    MenuAnchor::Below(trigger) => DomBridge.point_below(&trigger).await,
191                };
192                if let Ok(point) = point {
193                    let _ = DomBridge.place_at(&id, point).await;
194                }
195            });
196            // A new opening is a new search, and starts at an end.
197            let current = state.model.peek().clone();
198            if let Some(mut menu) = current {
199                if from_end {
200                    menu.focus_last();
201                } else {
202                    menu.focus_first();
203                }
204                menu.reset_typeahead();
205                state.model.set(Some(menu));
206            }
207            state.pending_focus.set(true);
208        } else if closing {
209            let restore = !*state.leaving.peek();
210            state.leaving.set(false);
211            spawn(async move {
212                let _ = DomBridge.toggle_popover(&id, false).await;
213                if restore {
214                    let _ = DomBridge.focus(&back).await;
215                }
216            });
217        }
218    }));
219    // The model follows the entries; the highlight survives when the item is still there.
220    // Compared item by item, not by count: a same-length list with different or newly
221    // disabled items must not keep steering arrows and typeahead by the old one.
222    if state.model.peek().as_ref().map(|m| signature(m.items()))
223        != built.as_ref().map(|m| signature(m.items()))
224        || state.model.peek().is_none()
225    {
226        let active = state
227            .model
228            .peek()
229            .as_ref()
230            .and_then(model::Menu::active_index);
231        let mut next = built.clone();
232        if let Some(menu) = &mut next {
233            menu.adopt_state(active, String::new());
234        }
235        state.model.set(next);
236    }
237    let active = state
238        .model
239        .read()
240        .as_ref()
241        .and_then(model::Menu::active_index);
242    let base = id.to_string();
243    let item_id = move |index: usize| format!("{base}-item-{index}");
244    let focus_id = id.clone();
245    use_effect(move || {
246        if (state.pending_focus)() {
247            state.pending_focus.set(false);
248            let target = state
249                .model
250                .peek()
251                .as_ref()
252                .and_then(model::Menu::active_index);
253            let menu_id = focus_id.clone();
254            spawn(async move {
255                let target = match target {
256                    Some(index) => Id::try_from(format!("{menu_id}-item-{index}")).ok(),
257                    // A menu with nothing actionable still takes focus, rather than leaving it
258                    // on the trigger behind an open menu.
259                    None => Some(menu_id),
260                };
261                if let Some(target) = target {
262                    let _ = DomBridge.focus(&target).await;
263                }
264            });
265        }
266    });
267    let mut attributes = component_attributes(
268        attributes,
269        ClassList::new(BEM.block()).extend(class.as_deref()),
270        &["role", "aria-label", "popover", "tabindex"],
271    );
272    attributes.push(Attribute::new("popover", "auto", None, false));
273    attributes.extend(open_attribute(open.then_some(true)));
274    let mut modeled = 0_usize;
275    let rows: Vec<(MenuEntry, Option<usize>)> = entries
276        .iter()
277        .map(|entry| {
278            let index = entry.modeled().map(|_| {
279                modeled += 1;
280                modeled - 1
281            });
282            (entry.clone(), index)
283        })
284        .collect();
285    let mut step = move |change: &dyn Fn(&mut model::Menu)| {
286        if let Some(menu) = state.model.write().as_mut() {
287            menu.reset_typeahead();
288            change(menu);
289        }
290        state.pending_focus.set(true);
291    };
292    rsx! {
293        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_MENU_CSS } }
294        div { id: "{id}", role: "menu", aria_label: label.to_string(), tabindex: "-1",
295            ontoggle: move |_| {
296                // The platform's light dismiss and Escape close the popover; opening fires
297                // the same event, so the first one after an open is skipped.
298                if (state.expecting_open)() { state.expecting_open.set(false); } else if open { onclose.call(()); }
299            },
300            onkeydown: move |event| {
301                let key = event.key();
302                match key {
303                    Key::ArrowDown => { event.prevent_default(); step(&|m| m.move_by(1)); }
304                    Key::ArrowUp => { event.prevent_default(); step(&|m| m.move_by(-1)); }
305                    Key::Home => { event.prevent_default(); step(&|m| m.focus_first()); }
306                    Key::End => { event.prevent_default(); step(&|m| m.focus_last()); }
307                    Key::Escape => { event.prevent_default(); onclose.call(()); }
308                    Key::Tab => { state.leaving.set(true); onclose.call(()); }
309                    Key::Character(text) if !event.modifiers().ctrl() && !event.modifiers().meta() && !event.modifiers().alt() && text != " " => {
310                        event.prevent_default();
311                        let moved = state.model.write().as_mut().is_some_and(|m| m.typeahead(&text));
312                        if moved { state.pending_focus.set(true); }
313                        // Characters typed within 500 ms of each other form one search.
314                        let generation = (state.typing)() + 1;
315                        state.typing.set(generation);
316                        spawn(async move {
317                            if DomBridge.delay(500).await.is_ok() && (state.typing)() == generation {
318                                if let Some(menu) = state.model.write().as_mut() { menu.reset_typeahead(); }
319                            }
320                        });
321                    }
322                    _ => {}
323                }
324            },
325            ..attributes,
326            for (row, (entry, index)) in rows.into_iter().enumerate() {
327                {entry_view(entry, index.map(|i| (i, item_id(i), active == Some(i))), row, onselect, onclose)}
328            }
329        }
330    }
331}
332
333fn entry_view(
334    entry: MenuEntry,
335    item: Option<(usize, String, bool)>,
336    row: usize,
337    onselect: EventHandler<String>,
338    onclose: EventHandler<()>,
339) -> Element {
340    let check = BEM.element("check");
341    let label_class = BEM.element("label");
342    match (entry, item) {
343        (MenuEntry::Group(label), _) => {
344            rsx! { div { key: "{row}", class: BEM.element("group-label"), role: "presentation", "{label}" } }
345        }
346        (MenuEntry::Separator, _) => {
347            rsx! { hr { key: "{row}", class: BEM.element("separator"), role: "separator" } }
348        }
349        (
350            MenuEntry::Item {
351                id,
352                label,
353                shortcut,
354                danger,
355                disabled,
356            },
357            Some((_, dom_id, active)),
358        ) => {
359            let class = ClassList::new(ITEM.block())
360                .with_modifier(
361                    ITEM,
362                    if danger {
363                        ItemTone::Danger
364                    } else {
365                        ItemTone::Normal
366                    },
367                )
368                .to_string();
369            rsx! {
370                button { key: "{row}", r#type: "button", class, role: "menuitem", id: dom_id, tabindex: if active { "0" } else { "-1" },
371                    aria_disabled: disabled.then_some("true"),
372                    onclick: move |_| if !disabled { onselect.call(id.clone()); onclose.call(()); },
373                    span { class: check, aria_hidden: "true" }
374                    span { class: label_class, "{label}" }
375                    if let Some(shortcut) = shortcut { span { class: BEM.element("shortcut"), aria_hidden: "true", "{shortcut}" } }
376                }
377            }
378        }
379        (
380            MenuEntry::Checkbox {
381                id,
382                label,
383                checked,
384                disabled,
385            },
386            Some((_, dom_id, active)),
387        ) => rsx! {
388            button { key: "{row}", r#type: "button", class: ITEM.block(), role: "menuitemcheckbox", id: dom_id,
389                tabindex: if active { "0" } else { "-1" }, aria_checked: checked.to_string(), aria_disabled: disabled.then_some("true"),
390                onclick: move |_| if !disabled { onselect.call(id.clone()); onclose.call(()); },
391                span { class: check, aria_hidden: "true", if checked { "✓" } }
392                span { class: label_class, "{label}" }
393            }
394        },
395        (MenuEntry::Submenu { id, label }, Some((_, dom_id, active))) => rsx! {
396            button { key: "{row}", r#type: "button", class: ITEM.block(), role: "menuitem", id: dom_id,
397                tabindex: if active { "0" } else { "-1" }, aria_haspopup: "menu",
398                onclick: move |_| onselect.call(id.clone()),
399                span { class: check, aria_hidden: "true" }
400                span { class: label_class, "{label}" }
401                span { class: BEM.element("submenu-arrow"), aria_hidden: "true" }
402            }
403        },
404        _ => rsx! {},
405    }
406}
407
408/// A button that opens a menu beneath itself, on the WAI-ARIA menu button pattern:
409/// click, Enter, or Space toggle it; Arrow Down opens at the first item and Arrow Up at
410/// the last. `aria-expanded` follows the menu.
411#[component]
412pub fn MenuButton(
413    /// Names the menu.
414    label: AccessibleName,
415    entries: Vec<MenuEntry>,
416    onselect: EventHandler<String>,
417    #[props(default)] variant: ButtonVariant,
418    #[props(default)] size: ButtonSize,
419    /// Select icon-only layout with a required accessible name.
420    #[props(default)]
421    icon_label: Option<AccessibleName>,
422    #[props(default)] availability: Availability,
423    children: Element,
424) -> Element {
425    let trigger = use_id();
426    let mut open = use_signal(|| false);
427    let mut from_end = use_signal(|| false);
428    let blocked = availability != Availability::Enabled;
429    let menu_id = trigger.suffixed("menu");
430    rsx! {
431        // The arrow keys reach the trigger's wrapper by bubbling; Button keeps its own
432        // click and availability handling.
433        span {
434            onkeydown: move |event: KeyboardEvent| {
435                if blocked { return; }
436                match event.key() {
437                    Key::ArrowDown => { event.prevent_default(); from_end.set(false); open.set(true); }
438                    Key::ArrowUp => { event.prevent_default(); from_end.set(true); open.set(true); }
439                    _ => {}
440                }
441            },
442            Button { variant, size, icon_label, availability, id: "{trigger}",
443                aria_haspopup: "menu", aria_expanded: open().to_string(), aria_controls: "{menu_id}",
444                onclick: move |_| { from_end.set(false); open.toggle(); },
445                {children}
446            }
447        }
448        Menu { label, entries, open: open(), from_end: from_end(), anchor: MenuAnchor::Below(trigger.clone()), return_focus: trigger.clone(),
449            onclose: move |()| open.set(false), onselect, menu_id }
450    }
451}
452
453/// A region whose right-click (or the context-menu key) opens a menu at the pointer,
454/// instead of the browser's own.
455#[component]
456pub fn ContextMenuArea(
457    label: AccessibleName,
458    entries: Vec<MenuEntry>,
459    onselect: EventHandler<String>,
460    #[props(default)] class: Option<String>,
461    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
462    children: Element,
463) -> Element {
464    let area = use_id();
465    let mut at = use_signal(|| None::<MenuPoint>);
466    let attributes = component_attributes(
467        attributes,
468        ClassList::default().extend(class.as_deref()),
469        &["id"],
470    );
471    let attributes: Vec<_> = attributes
472        .into_iter()
473        .filter(|a| {
474            !(a.name == "class"
475                && matches!(&a.value, dioxus::core::AttributeValue::Text(t) if t.is_empty()))
476        })
477        .collect();
478    rsx! {
479        div { id: "{area}", tabindex: "0",
480            oncontextmenu: move |event| {
481                event.prevent_default();
482                let point = event.client_coordinates();
483                at.set(Some(MenuPoint { x: point.x, y: point.y, flip_y: None, edge: None }));
484            },
485            ..attributes,
486            {children}
487        }
488        Menu { label, entries, open: at().is_some(),
489            anchor: MenuAnchor::At(at().unwrap_or(MenuPoint { x: 0.0, y: 0.0, flip_y: None, edge: None })),
490            return_focus: area.clone(), onclose: move |()| at.set(None), onselect }
491    }
492}
493
494/// What navigation depends on in a menu item; `MenuItem` has no `PartialEq` upstream.
495fn signature(items: &[model::MenuItem]) -> Vec<(&str, &str, bool, bool, bool)> {
496    items
497        .iter()
498        .map(|item| {
499            (
500                item.id.as_str(),
501                item.label.as_str(),
502                item.disabled,
503                item.checked,
504                matches!(item.kind, model::ItemKind::Separator),
505            )
506        })
507        .collect()
508}