Skip to main content

nightshade_api/web/
menus.rs

1//! Menu, submenu, context-menu, and tab-bar components with keyboard roving focus.
2
3use leptos::html;
4use leptos::portal::Portal;
5use leptos::prelude::*;
6use wasm_bindgen::JsCast;
7use web_sys::{HtmlElement, KeyboardEvent};
8
9fn active_element() -> Option<HtmlElement> {
10    web_sys::window()?
11        .document()?
12        .active_element()
13        .and_then(|element| element.dyn_into::<HtmlElement>().ok())
14}
15
16fn same_element(left: &HtmlElement, right: &HtmlElement) -> bool {
17    let left: &wasm_bindgen::JsValue = left.as_ref();
18    let right: &wasm_bindgen::JsValue = right.as_ref();
19    left == right
20}
21
22fn items_within(container: &HtmlElement) -> Vec<HtmlElement> {
23    let Ok(list) =
24        container.query_selector_all(".nightshade-menu-item:not([aria-disabled='true'])")
25    else {
26        return Vec::new();
27    };
28    (0..list.length())
29        .filter_map(|index| list.item(index))
30        .filter_map(|node| node.dyn_into::<HtmlElement>().ok())
31        .collect()
32}
33
34fn roving_focus(container: &HtmlElement, event: &KeyboardEvent) -> bool {
35    let items = items_within(container);
36    if items.is_empty() {
37        return false;
38    }
39    let current = active_element()
40        .and_then(|active| items.iter().position(|item| same_element(item, &active)));
41    match event.key().as_str() {
42        "ArrowDown" => {
43            event.prevent_default();
44            let next = current.map(|index| (index + 1) % items.len()).unwrap_or(0);
45            let _ = items[next].focus();
46            true
47        }
48        "ArrowUp" => {
49            event.prevent_default();
50            let next = current
51                .map(|index| (index + items.len() - 1) % items.len())
52                .unwrap_or(items.len() - 1);
53            let _ = items[next].focus();
54            true
55        }
56        "Home" => {
57            event.prevent_default();
58            let _ = items[0].focus();
59            true
60        }
61        "End" => {
62            event.prevent_default();
63            let _ = items[items.len() - 1].focus();
64            true
65        }
66        _ => false,
67    }
68}
69
70/// A `label` button that toggles a dropdown of its `children`, closing on outside click or Escape and moving focus with the arrow, Home, and End keys.
71#[component]
72pub fn Menu(#[prop(into)] label: String, children: ChildrenFn) -> impl IntoView {
73    let open = RwSignal::new(false);
74    let root = NodeRef::<html::Div>::new();
75    let list_ref = NodeRef::<html::Div>::new();
76    let children = StoredValue::new(children);
77
78    let handle = window_event_listener(leptos::ev::mousedown, move |event| {
79        if !open.get_untracked() {
80            return;
81        }
82        let inside = root.get_untracked().is_some_and(|node| {
83            event
84                .target()
85                .and_then(|target| target.dyn_into::<web_sys::Node>().ok())
86                .is_some_and(|target| node.contains(Some(&target)))
87        });
88        if !inside {
89            open.set(false);
90        }
91    });
92    on_cleanup(move || handle.remove());
93
94    Effect::new(move |_| {
95        if open.get()
96            && let Some(list) = list_ref.get()
97            && let Some(first) = items_within(&list).into_iter().next()
98        {
99            let _ = first.focus();
100        }
101    });
102
103    let on_keydown = move |event: KeyboardEvent| {
104        if event.key() == "Escape" {
105            event.prevent_default();
106            open.set(false);
107            return;
108        }
109        if let Some(list) = list_ref.get() {
110            roving_focus(&list, &event);
111        }
112    };
113
114    view! {
115        <div class="nightshade-menu" node_ref=root>
116            <button
117                class="nightshade-button"
118                aria-haspopup="menu"
119                aria-expanded=move || open.get().to_string()
120                on:click=move |_| open.update(|value| *value = !*value)
121            >
122                {label}
123            </button>
124            <Show when=move || open.get() fallback=|| ()>
125                <div
126                    class="nightshade-menu-list"
127                    role="menu"
128                    node_ref=list_ref
129                    on:keydown=on_keydown
130                    on:click=move |_| open.set(false)
131                >
132                    {children.with_value(|render| render())}
133                </div>
134            </Show>
135        </div>
136    }
137}
138
139/// A single selectable menu row that fires `on_select` on click or Enter/Space.
140///
141/// Passing a `checked` signal renders it as a checkbox item; `disabled` blocks
142/// selection, and `shortcut` shows a trailing key hint.
143#[component]
144pub fn MenuItem(
145    #[prop(into)] label: String,
146    #[prop(into, optional)] shortcut: String,
147    #[prop(optional)] checked: Option<Signal<bool>>,
148    #[prop(into, optional)] disabled: Signal<bool>,
149    #[prop(optional)] on_select: Option<Callback<()>>,
150) -> impl IntoView {
151    let role = if checked.is_some() {
152        "menuitemcheckbox"
153    } else {
154        "menuitem"
155    };
156    let handle = move |_event: web_sys::MouseEvent| {
157        if disabled.get_untracked() {
158            return;
159        }
160        if let Some(callback) = on_select {
161            callback.run(());
162        }
163    };
164    let on_keydown = move |event: KeyboardEvent| {
165        if matches!(event.key().as_str(), "Enter" | " ")
166            && !disabled.get_untracked()
167            && let Some(callback) = on_select
168        {
169            event.prevent_default();
170            callback.run(());
171        }
172    };
173    view! {
174        <div
175            class="nightshade-menu-item"
176            role=role
177            tabindex="-1"
178            aria-disabled=move || disabled.get().to_string()
179            aria-checked=checked.map(|checked| move || checked.get().to_string())
180            on:click=handle
181            on:keydown=on_keydown
182        >
183            {checked
184                .map(|checked| {
185                    view! {
186                        <span class="nightshade-menu-check" class:on=move || checked.get()>
187                            "\u{2713}"
188                        </span>
189                    }
190                })}
191            <span class="nightshade-menu-label">{label}</span>
192            <span class="shortcut">{shortcut}</span>
193        </div>
194    }
195}
196
197/// A horizontal divider rendered between groups of menu items.
198#[component]
199pub fn MenuSeparator() -> impl IntoView {
200    view! { <div class="nightshade-menu-separator" role="separator"></div> }
201}
202
203/// A nested menu row that reveals its `children` in a flyout on hover or click.
204#[component]
205pub fn Submenu(#[prop(into)] label: String, children: ChildrenFn) -> impl IntoView {
206    let open = RwSignal::new(false);
207    let children = StoredValue::new(children);
208    view! {
209        <div
210            class="nightshade-submenu"
211            on:mouseenter=move |_| open.set(true)
212            on:mouseleave=move |_| open.set(false)
213        >
214            <div
215                class="nightshade-menu-item"
216                role="menuitem"
217                tabindex="-1"
218                aria-haspopup="menu"
219                aria-expanded=move || open.get().to_string()
220                on:click=move |event| {
221                    event.stop_propagation();
222                    open.update(|value| *value = !*value);
223                }
224            >
225                <span class="nightshade-menu-label">{label}</span>
226                <span class="nightshade-menu-arrow">"\u{203a}"</span>
227            </div>
228            <Show when=move || open.get() fallback=|| ()>
229                <div class="nightshade-menu-list submenu" role="menu">
230                    {children.with_value(|render| render())}
231                </div>
232            </Show>
233        </div>
234    }
235}
236
237/// A menu portaled to the document at position (`x`, `y`), shown while `open` is `true`.
238///
239/// Clicking the backdrop or pressing Escape closes it; arrow, Home, and End
240/// keys move focus across its `children`.
241#[component]
242pub fn ContextMenu(
243    open: RwSignal<bool>,
244    #[prop(into)] x: Signal<i32>,
245    #[prop(into)] y: Signal<i32>,
246    children: ChildrenFn,
247) -> impl IntoView {
248    let list_ref = NodeRef::<html::Div>::new();
249    let children = StoredValue::new(children);
250
251    Effect::new(move |_| {
252        if open.get()
253            && let Some(list) = list_ref.get()
254            && let Some(first) = items_within(&list).into_iter().next()
255        {
256            let _ = first.focus();
257        }
258    });
259
260    let on_keydown = move |event: KeyboardEvent| {
261        if event.key() == "Escape" {
262            event.prevent_default();
263            open.set(false);
264            return;
265        }
266        if let Some(list) = list_ref.get() {
267            roving_focus(&list, &event);
268        }
269    };
270
271    view! {
272        <Show when=move || open.get() fallback=|| ()>
273            <Portal>
274                <div class="nightshade-context-scrim" on:mousedown=move |_| open.set(false)>
275                    <div
276                        class="nightshade-context-menu"
277                        role="menu"
278                        node_ref=list_ref
279                        style=move || format!("left:{}px;top:{}px", x.get(), y.get())
280                        on:mousedown=|event| event.stop_propagation()
281                        on:keydown=on_keydown
282                        on:click=move |_| open.set(false)
283                    >
284                        {children.with_value(|render| render())}
285                    </div>
286                </div>
287            </Portal>
288        </Show>
289    }
290}
291
292/// A horizontal row of tabs from `(id, label)` pairs, binding the selected id to `active` and cycling selection with the left and right arrow keys.
293#[component]
294pub fn TabBar(tabs: Vec<(String, String)>, active: RwSignal<String>) -> impl IntoView {
295    let ids: Vec<String> = tabs.iter().map(|(id, _)| id.clone()).collect();
296    let on_keydown = move |event: KeyboardEvent| {
297        let current = ids.iter().position(|id| id == &active.get_untracked());
298        let Some(current) = current else {
299            return;
300        };
301        match event.key().as_str() {
302            "ArrowRight" => {
303                event.prevent_default();
304                active.set(ids[(current + 1) % ids.len()].clone());
305            }
306            "ArrowLeft" => {
307                event.prevent_default();
308                active.set(ids[(current + ids.len() - 1) % ids.len()].clone());
309            }
310            _ => {}
311        }
312    };
313    view! {
314        <div class="nightshade-tab-bar" role="tablist" on:keydown=on_keydown>
315            {tabs
316                .into_iter()
317                .map(|(id, label)| {
318                    let id_for_class = id.clone();
319                    let id_for_aria = id.clone();
320                    let id_for_tabindex = id.clone();
321                    view! {
322                        <button
323                            role="tab"
324                            aria-selected=move || (active.get() == id_for_aria).to_string()
325                            tabindex=move || {
326                                if active.get() == id_for_tabindex { "0" } else { "-1" }
327                            }
328                            class=move || {
329                                if active.get() == id_for_class {
330                                    "nightshade-tab active"
331                                } else {
332                                    "nightshade-tab"
333                                }
334                            }
335                            on:click=move |_| active.set(id.clone())
336                        >
337                            {label}
338                        </button>
339                    }
340                })
341                .collect_view()}
342        </div>
343    }
344}