Skip to main content

yew_nav_link/components/
dropdown.rs

1// SPDX-FileCopyrightText: RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! # `NavDropdown`
5//!
6//! Collapsible dropdown menu for grouping related navigation items.
7//! Renders a `<li>` with a toggle button and a nested `<ul>` menu.
8//!
9//! # Example
10//!
11//! ```rust
12//! use yew::prelude::*;
13//! use yew_nav_link::{
14//!     NavItem, NavLink, NavList,
15//!     components::{NavDropdown, NavDropdownDivider, NavDropdownItem}
16//! };
17//! use yew_router::prelude::*;
18//!
19//! # #[derive(Clone, PartialEq, Routable)]
20//! # enum Route {
21//! #     #[at("/")]
22//! #     Home,
23//! #     #[at("/settings")]
24//! #     Settings,
25//! # }
26//! #[component]
27//! fn Nav() -> Html {
28//!     html! {
29//!         <NavList>
30//!             <NavItem>
31//!                 <NavLink<Route> to={Route::Home}>{ "Home" }</NavLink<Route>>
32//!             </NavItem>
33//!             <NavDropdown toggle_text="Settings">
34//!                 <NavDropdownItem>
35//!                     <NavLink<Route> to={Route::Settings}>{ "Profile" }</NavLink<Route>>
36//!                 </NavDropdownItem>
37//!                 <NavDropdownDivider />
38//!                 <NavDropdownItem disabled=true>
39//!                     { "Admin" }
40//!                 </NavDropdownItem>
41//!             </NavDropdown>
42//!         </NavList>
43//!     }
44//! }
45//! ```
46//!
47//! # CSS Classes
48//!
49//! | Class | Condition |
50//! |-------|-----------|
51//! | `nav-dropdown` | Always on container `<li>` |
52//! | `nav-dropdown-toggle` | Toggle button |
53//! | `nav-dropdown-menu` | Inner `<ul>` |
54//! | `nav-dropdown-caret` | Caret indicator |
55//! | `nav-dropdown-item` | Menu items |
56//! | `nav-dropdown-divider` | Separator |
57//! | `disabled` | Applied to disabled items |
58//!
59//! # Props
60//!
61//! **`NavDropdown`:**
62//!
63//! | Prop | Type | Default | Description |
64//! |------|------|---------|-------------|
65//! | `toggle_text` | `AttrValue` | `"dropdown"` | Toggle button label |
66//! | `id` | `Option<AttrValue>` | `None` | Menu `<ul>` id, also wired to the toggle's `aria-controls` |
67//! | `classes` | `Classes` | — | Additional CSS classes |
68//! | `children` | `Children` | — | Menu content |
69//!
70//! **`NavDropdownItem`:**
71//!
72//! | Prop | Type | Default | Description |
73//! |------|------|---------|-------------|
74//! | `disabled` | `bool` | `false` | Disable the item |
75//! | `classes` | `Classes` | — | Additional CSS classes |
76//! | `children` | `Children` | — | Item content |
77//!
78//! **`NavDropdownDivider`:**
79//!
80//! | Prop | Type | Default | Description |
81//! |------|------|---------|-------------|
82//! | `classes` | `Classes` | — | Additional CSS classes |
83
84use wasm_bindgen::JsCast;
85use web_sys::{HtmlElement, Node};
86use yew::prelude::*;
87
88use super::focus::{focusable_elements, focused_position, next_focus_index};
89
90/// Collects the focusable elements (links and enabled buttons) inside the
91/// dropdown menu, in DOM order.
92///
93/// Elements nested in a disabled [`NavDropdownItem`] are excluded, so
94/// keyboard navigation never lands on a link the item's disabled state is
95/// supposed to neutralize.
96fn menu_items(menu_ref: &NodeRef) -> Vec<HtmlElement> {
97    focusable_elements(menu_ref, "a[href], button:not([disabled])")
98        .into_iter()
99        .filter(|element| {
100            element
101                .closest(".nav-dropdown-item.disabled")
102                .ok()
103                .flatten()
104                .is_none()
105        })
106        .collect()
107}
108
109/// Properties for the [`NavDropdown`] component.
110///
111/// | Prop | Type | Default | Description |
112/// |------|------|---------|-------------|
113/// | `toggle_text` | `AttrValue` | `"dropdown"` | Toggle button label |
114/// | `id` | `Option<AttrValue>` | `None` | Menu `<ul>` id, also wired to the toggle's `aria-controls` |
115/// | `classes` | `Classes` | — | Additional CSS classes |
116/// | `children` | `Children` | — | Menu content |
117#[derive(Properties, Clone, PartialEq, Debug, Default)]
118pub struct NavDropdownProps {
119    /// Additional CSS classes applied to the dropdown container.
120    #[prop_or_default]
121    pub classes: Classes,
122
123    /// Text displayed on the dropdown toggle button.
124    #[prop_or(AttrValue::Static("dropdown"))]
125    pub toggle_text: AttrValue,
126
127    /// Optional `id` for the menu `<ul>`; when set, the toggle references it
128    /// via `aria-controls`.
129    #[prop_or_default]
130    pub id: Option<AttrValue>,
131
132    /// Content rendered inside the dropdown menu.
133    #[prop_or_default]
134    pub children: Children
135}
136
137/// Collapsible dropdown menu for grouping navigation links.
138///
139/// # Keyboard & accessibility
140///
141/// Implements the WAI-ARIA disclosure-navigation pattern: the toggle carries
142/// `aria-expanded` (plus `aria-controls` when `id` is set) and the menu stays
143/// a plain list of links in the normal tab order — no `menu`/`menuitem`
144/// roles, which the APG reserves for application menus rather than site
145/// navigation.
146///
147/// Arrow-key support is layered on top as the optional enhancement the APG
148/// describes: opening the menu focuses the first item (`ArrowUp` on a closed
149/// toggle opens it and focuses the last), `ArrowDown`/`ArrowUp` (wrapping)
150/// and `Home`/`End` move focus over the menu's links, `Escape` closes the
151/// menu and returns focus to the toggle, and moving focus out of the
152/// dropdown (tabbing away or clicking elsewhere) dismisses it.
153///
154/// # CSS Classes
155///
156/// - `nav-dropdown` - Container `<li>` element
157/// - `nav-dropdown-toggle` - Toggle button
158/// - `nav-dropdown-menu` - Inner `<ul>` menu
159/// - `nav-dropdown-caret` - Caret indicator
160#[function_component]
161pub fn NavDropdown(props: &NavDropdownProps) -> Html {
162    let mut classes = props.classes.clone();
163    classes.push("nav-dropdown");
164
165    let open = use_state(|| false);
166    let open_focus_last = use_mut_ref(|| false);
167    let container_ref = use_node_ref();
168    let toggle_ref = use_node_ref();
169    let menu_ref = use_node_ref();
170
171    let on_toggle = {
172        let open = open.clone();
173        let open_focus_last = open_focus_last.clone();
174        Callback::from(move |e: MouseEvent| {
175            e.stop_propagation();
176            *open_focus_last.borrow_mut() = false;
177            open.set(!*open);
178        })
179    };
180
181    let on_keydown = {
182        let open = open.clone();
183        let open_focus_last = open_focus_last.clone();
184        let toggle_ref = toggle_ref.clone();
185        let menu_ref = menu_ref.clone();
186        Callback::from(move |event: KeyboardEvent| {
187            let key = event.key();
188
189            if key == "Escape" && *open {
190                event.prevent_default();
191                open.set(false);
192                if let Some(toggle) = toggle_ref.cast::<HtmlElement>() {
193                    let _ = toggle.focus();
194                }
195                return;
196            }
197
198            if !matches!(key.as_str(), "ArrowDown" | "ArrowUp" | "Home" | "End") {
199                return;
200            }
201
202            let items = menu_items(&menu_ref);
203            if items.is_empty() {
204                return;
205            }
206
207            event.prevent_default();
208            if !*open {
209                *open_focus_last.borrow_mut() = key == "ArrowUp";
210                open.set(true);
211                return;
212            }
213
214            let position = focused_position(&items);
215
216            if let Some(item) = next_focus_index(&key, position, items.len(), true)
217                .and_then(|index| items.get(index))
218            {
219                let _ = item.focus();
220            }
221        })
222    };
223
224    let on_focusout = {
225        let open = open.clone();
226        let container_ref = container_ref.clone();
227        Callback::from(move |event: FocusEvent| {
228            if !*open {
229                return;
230            }
231            let stays_inside = match (container_ref.cast::<Node>(), event.related_target()) {
232                (Some(container), Some(related)) => related
233                    .dyn_into::<Node>()
234                    .is_ok_and(|node| container.contains(Some(&node))),
235                _ => false
236            };
237            if !stays_inside {
238                open.set(false);
239            }
240        })
241    };
242
243    {
244        let menu_ref = menu_ref.clone();
245        use_effect_with(*open, move |is_open| {
246            if *is_open {
247                let items = menu_items(&menu_ref);
248                let focus_last = std::mem::take(&mut *open_focus_last.borrow_mut());
249                let target = if focus_last {
250                    items.last()
251                } else {
252                    items.first()
253                };
254                if let Some(item) = target {
255                    let _ = item.focus();
256                }
257            }
258            || ()
259        });
260    }
261
262    let menu_class = if *open {
263        "nav-dropdown-menu open"
264    } else {
265        "nav-dropdown-menu"
266    };
267
268    html! {
269        <li
270            ref={container_ref}
271            class={classes}
272            onkeydown={on_keydown}
273            onfocusout={on_focusout}
274        >
275            <button
276                ref={toggle_ref}
277                type="button"
278                class="nav-dropdown-toggle"
279                aria-expanded={if *open { "true" } else { "false" }}
280                aria-controls={props.id.clone()}
281                onclick={on_toggle}
282            >
283                { props.toggle_text.clone() }
284                <span class="nav-dropdown-caret" aria-hidden="true">{" ▼"}</span>
285            </button>
286            <ul ref={menu_ref} id={props.id.clone()} class={menu_class}>
287                { for props.children.iter() }
288            </ul>
289        </li>
290    }
291}
292
293/// Properties for the [`NavDropdownItem`] component.
294///
295/// | Prop | Type | Default | Description |
296/// |------|------|---------|-------------|
297/// | `disabled` | `bool` | `false` | Disable the item |
298/// | `classes` | `Classes` | — | Additional CSS classes |
299/// | `children` | `Children` | — | Item content |
300#[derive(Properties, Clone, PartialEq, Debug, Default)]
301pub struct NavDropdownItemProps {
302    /// Additional CSS classes applied to the item.
303    #[prop_or_default]
304    pub classes: Classes,
305
306    /// Whether the dropdown item is disabled.
307    #[prop_or_default]
308    pub disabled: bool,
309
310    /// Content rendered inside the item.
311    pub children: Children
312}
313
314/// A single item within a [`NavDropdown`] menu.
315///
316/// Renders a plain `<li>`; a disabled item additionally carries
317/// `aria-disabled="true"` and its links are skipped by the dropdown's
318/// keyboard navigation.
319///
320/// # CSS Classes
321///
322/// - `nav-dropdown-item` - Always applied
323/// - `disabled` - Applied when `disabled` is `true`
324#[function_component]
325pub fn NavDropdownItem(props: &NavDropdownItemProps) -> Html {
326    let mut classes = props.classes.clone();
327    classes.push("nav-dropdown-item");
328
329    if props.disabled {
330        classes.push("disabled");
331    }
332
333    let aria_disabled = props.disabled.then_some("true");
334
335    html! {
336        <li class={classes} aria-disabled={aria_disabled}>
337            { for props.children.iter() }
338        </li>
339    }
340}
341
342/// Properties for the [`NavDropdownDivider`] component.
343///
344/// | Prop | Type | Default | Description |
345/// |------|------|---------|-------------|
346/// | `classes` | `Classes` | — | Additional CSS classes |
347#[derive(Properties, Clone, PartialEq, Eq, Debug, Default)]
348pub struct NavDropdownDividerProps {
349    /// Additional CSS classes applied to the divider.
350    #[prop_or_default]
351    pub classes: Classes
352}
353
354/// Visual separator between items in a [`NavDropdown`] menu.
355///
356/// Renders a `<li>` element with `role="separator"`.
357#[function_component]
358pub fn NavDropdownDivider(props: &NavDropdownDividerProps) -> Html {
359    let mut classes = props.classes.clone();
360    classes.push("nav-dropdown-divider");
361
362    html! {
363        <li class={classes} role="separator" />
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn nav_dropdown_props_default() {
373        let props = NavDropdownProps {
374            classes:     Classes::default(),
375            toggle_text: AttrValue::Static("Menu"),
376            id:          None,
377            children:    Children::new(vec![])
378        };
379
380        assert_eq!(props.toggle_text, "Menu");
381        assert!(props.id.is_none());
382    }
383
384    #[test]
385    fn nav_dropdown_item_default() {
386        let props = NavDropdownItemProps {
387            classes:  Classes::default(),
388            disabled: false,
389            children: Children::new(vec![])
390        };
391
392        assert!(!props.disabled);
393    }
394
395    #[test]
396    fn nav_dropdown_item_disabled() {
397        let props = NavDropdownItemProps {
398            classes:  Classes::default(),
399            disabled: true,
400            children: Children::new(vec![])
401        };
402
403        assert!(props.disabled);
404    }
405
406    #[test]
407    fn nav_dropdown_divider_props() {
408        let props = NavDropdownDividerProps {
409            classes: Classes::default()
410        };
411
412        assert!(props.classes.is_empty());
413    }
414
415    #[test]
416    fn nav_dropdown_with_custom_id() {
417        let props = NavDropdownProps {
418            classes:     Classes::default(),
419            toggle_text: AttrValue::Static("Menu"),
420            id:          Some(AttrValue::Static("my-dropdown")),
421            children:    Children::new(vec![])
422        };
423
424        assert_eq!(props.id.as_deref(), Some("my-dropdown"));
425    }
426
427    #[test]
428    fn nav_dropdown_item_with_classes() {
429        let mut classes = Classes::new();
430        classes.push("custom-item");
431        let props = NavDropdownItemProps {
432            classes,
433            disabled: false,
434            children: Children::new(vec![])
435        };
436
437        assert!(props.classes.contains("custom-item"));
438    }
439
440    #[test]
441    fn nav_dropdown_disabled_item() {
442        let props = NavDropdownItemProps {
443            classes:  Classes::default(),
444            disabled: true,
445            children: Children::new(vec![])
446        };
447
448        assert!(props.disabled);
449    }
450
451    #[test]
452    fn nav_dropdown_with_children() {
453        let children = Children::new(vec![html! { <div>{ "child" }</div> }]);
454        let props = NavDropdownProps {
455            classes: Classes::default(),
456            toggle_text: AttrValue::Static("Test"),
457            id: None,
458            children
459        };
460
461        assert_eq!(props.children.len(), 1);
462    }
463}