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}