Skip to main content

hypersig_ui/
menu.rs

1//! Menu buttons: a button that opens a menu of actions in a popover.
2
3use std::fmt;
4use std::marker::PhantomData;
5
6use hypersig::html::{El, Key, Orientation, button as button_el, div};
7use hypersig::style::value::{
8    AlignItems, BorderStyle, Color, Cursor, Display, PositionTryFallbacks, TextAlign, px, raw, rem,
9};
10use hypersig::style::{PseudoClass, Style};
11use hypersig::{Expr, IntoExpr, IntoStmt, NodeRef, Stmt};
12
13use crate::button::{ButtonSize, ButtonVariant, button};
14use crate::id::check_name;
15use crate::look;
16use crate::token;
17
18/// A menu of actions on a page whose signals are `S`, opened by a button.
19///
20/// Declare one as a `const` and use the same value for the button and the
21/// menu, so they agree on the menu's id. It follows the WAI-ARIA menu button
22/// pattern: the button has `aria-haspopup="menu"`, the menu `role="menu"` and
23/// each action `role="menuitem"`. The menu is a `popover`, so the browser
24/// puts it above the page, closes it on Escape or on a click outside, and
25/// returns focus to the button. When it opens, focus moves to the first
26/// action. The arrow keys, Home, and End move between actions, an action
27/// runs its statement and closes the menu, and Tab closes it.
28///
29/// The menu is placed under the button with CSS anchor positioning and flips
30/// above it where there is no room below. The menu carries a node ref with
31/// its name, so two menus with one name on a page make
32/// [`Page::render`](hypersig::Page::render) panic.
33///
34/// ```
35/// use hypersig::Signals;
36/// use hypersig_ui::{ButtonSize, ButtonVariant, Menu, MenuItem};
37/// use serde::{Deserialize, Serialize};
38///
39/// #[derive(Signals, Serialize, Deserialize)]
40/// struct Doc {
41///     archived: bool,
42/// }
43///
44/// const ACTIONS: Menu<Doc> = Menu::new("doc-actions");
45///
46/// let open = ACTIONS.trigger(ButtonVariant::Outline, ButtonSize::Default).text("Actions");
47/// let menu = ACTIONS.menu([
48///     MenuItem::new("Archive", Doc::archived().set(true)),
49///     MenuItem::new("Delete", Doc::archived().set(false)).destructive(),
50/// ]);
51/// assert!(open.render().contains(r#"aria-haspopup="menu""#));
52/// assert!(menu.render().contains(r#"role="menuitem""#));
53/// ```
54pub struct Menu<S> {
55    name: &'static str,
56    marker: PhantomData<fn() -> S>,
57}
58
59impl<S> Menu<S> {
60    /// A menu whose id is `name`.
61    ///
62    /// # Panics
63    ///
64    /// Panics if `name` is empty or holds a character other than an ASCII
65    /// letter, digit, `-`, or `_`. In a `const`, that is a compile error.
66    #[must_use]
67    pub const fn new(name: &'static str) -> Self {
68        check_name(name);
69        Self {
70            name,
71            marker: PhantomData,
72        }
73    }
74
75    fn trigger_id(self) -> String {
76        format!("{}-trigger", self.name)
77    }
78
79    fn anchor(self) -> String {
80        format!("--{}", self.name)
81    }
82
83    /// The button that opens and closes the menu.
84    #[must_use]
85    pub fn trigger(self, variant: ButtonVariant, size: ButtonSize) -> El<S> {
86        button(variant, size)
87            .id(self.trigger_id())
88            .attr("aria-haspopup", "menu")
89            .attr("commandfor", self.name)
90            .attr("command", "toggle-popover")
91            .css(Style::new().anchor_name(raw(self.anchor())))
92    }
93
94    /// The menu, holding `items` in order.
95    #[must_use]
96    pub fn menu(self, items: impl IntoIterator<Item = MenuItem<S>>) -> El<S> {
97        let node_ref = NodeRef::new(self.name);
98        div()
99            .id(self.name)
100            .attr("popover", "auto")
101            .attr("role", "menu")
102            .attr("aria-labelledby", self.trigger_id())
103            .node_ref(node_ref)
104            .roving_focus(Orientation::Vertical)
105            // Tab and Shift+Tab both leave the menu, which closes it.
106            .on_key(Key::Tab, node_ref.hide_popover())
107            .on_key(Key::Tab.shift(), node_ref.hide_popover())
108            .css(menu_style(self.anchor()))
109            .children(items.into_iter().enumerate().map(|(index, item)| {
110                let mut action = button_el()
111                    .attr("type", "button")
112                    .attr("role", "menuitem")
113                    .attr("tabindex", "-1")
114                    .css(item_style(item.destructive))
115                    // The menu closes from the statement rather than from a
116                    // `command`, because a statement that disables this item
117                    // makes the browser skip a disabled button's command.
118                    .on_click(item.run.then(node_ref.hide_popover()))
119                    .text(item.label);
120                // The browser focuses an element with `autofocus` inside a
121                // popover as the popover opens.
122                if index == 0 {
123                    action = action.attr("autofocus", "");
124                }
125                if let Some(disabled) = item.disabled {
126                    action = action.disabled(disabled);
127                }
128                action
129            }))
130    }
131}
132
133impl<S> Clone for Menu<S> {
134    fn clone(&self) -> Self {
135        *self
136    }
137}
138
139impl<S> Copy for Menu<S> {}
140
141impl<S> fmt::Debug for Menu<S> {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.debug_tuple("Menu").field(&self.name).finish()
144    }
145}
146
147/// One action in a [`Menu`].
148#[derive(Debug)]
149pub struct MenuItem<S> {
150    label: String,
151    run: Stmt<S>,
152    destructive: bool,
153    disabled: Option<Expr<S, bool>>,
154}
155
156impl<S> MenuItem<S> {
157    /// An action labeled `label` that runs `run` when chosen.
158    #[must_use]
159    pub fn new(label: impl Into<String>, run: impl IntoStmt<S>) -> Self {
160        Self {
161            label: label.into(),
162            run: run.into_stmt(),
163            destructive: false,
164            disabled: None,
165        }
166    }
167
168    /// Draws the action in [`token::destructive`]. It is for an action that deletes.
169    #[must_use]
170    pub fn destructive(mut self) -> Self {
171        self.destructive = true;
172        self
173    }
174
175    /// Disables the action while `when` is true, which the arrow keys then
176    /// skip.
177    #[must_use]
178    pub fn disabled(mut self, when: impl IntoExpr<S, bool>) -> Self {
179        self.disabled = Some(when.into_expr());
180        self
181    }
182}
183
184fn menu_style(anchor: String) -> Style {
185    // A popover is centered in the viewport by the browser's `inset: 0` and
186    // `margin: auto`, which the anchor position replaces.
187    Style::new()
188        .position_anchor(raw(anchor))
189        .position_area(raw("block-end span-inline-end"))
190        .position_try_fallbacks(PositionTryFallbacks::FlipBlock)
191        .inset(raw("auto"))
192        .margin(px(0))
193        .margin_top(rem(0.25))
194        .min_width(rem(8))
195        .padding(rem(0.25))
196        .border_width(px(1))
197        .border_style(BorderStyle::Solid)
198        .border_color(token::border().var())
199        .border_radius(px(6))
200        .background_color(token::popover().var())
201        .color(token::popover_foreground().var())
202        .box_shadow(raw(
203            "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
204        ))
205}
206
207fn item_style(destructive: bool) -> Style {
208    let text = if destructive {
209        token::destructive().var()
210    } else {
211        token::popover_foreground().var()
212    };
213    Style::new()
214        .display(Display::Flex)
215        .align_items(AlignItems::Center)
216        .gap(rem(0.5))
217        .width(hypersig::style::value::pct(100))
218        .padding_block(rem(0.375))
219        .padding_inline(rem(0.5))
220        .border_style(BorderStyle::None)
221        .border_radius(px(4))
222        .background_color(Color::TRANSPARENT)
223        .color(text)
224        .text_align(TextAlign::Start)
225        .cursor(Cursor::Pointer)
226        .merge(look::small_text())
227        .on(
228            PseudoClass::Hover,
229            Style::new().background_color(token::accent().var()),
230        )
231        .on(
232            PseudoClass::Focus,
233            Style::new().background_color(token::accent().var()),
234        )
235        .on(PseudoClass::FocusVisible, look::ring(px(-2)))
236        .merge(look::disabled())
237}
238
239#[cfg(test)]
240mod tests {
241    use hypersig::html::div;
242
243    use super::Menu;
244
245    struct Page;
246
247    const ACTIONS: Menu<Page> = Menu::new("actions");
248
249    #[test]
250    #[should_panic(expected = "`actions` where the runtime can reach both")]
251    fn two_menus_with_one_name_panic_when_rendered() {
252        let _ = div::<Page>()
253            .child(ACTIONS.menu([]))
254            .child(ACTIONS.menu([]))
255            .render();
256    }
257}