Skip to main content

yew_nav_link/components/
dropdown.rs

1// SPDX-FileCopyrightText: 2024-2026 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//!             <NavLink<Route> to={Route::Home}>{ "Home" }</NavLink<Route>>
31//!             <NavDropdown toggle_text="Settings">
32//!                 <NavDropdownItem>
33//!                     <NavLink<Route> to={Route::Settings}>{ "Profile" }</NavLink<Route>>
34//!                 </NavDropdownItem>
35//!                 <NavDropdownDivider />
36//!                 <NavDropdownItem disabled=true>
37//!                     { "Admin" }
38//!                 </NavDropdownItem>
39//!             </NavDropdown>
40//!         </NavList>
41//!     }
42//! }
43//! ```
44//!
45//! # CSS Classes
46//!
47//! | Class | Condition |
48//! |-------|-----------|
49//! | `nav-dropdown` | Always on container `<li>` |
50//! | `nav-dropdown-toggle` | Toggle button |
51//! | `nav-dropdown-menu` | Inner `<ul>` |
52//! | `nav-dropdown-caret` | Caret indicator |
53//! | `nav-dropdown-item` | Menu items |
54//! | `nav-dropdown-divider` | Separator |
55//! | `disabled` | Applied to disabled items |
56//!
57//! # Props
58//!
59//! **`NavDropdown`:**
60//!
61//! | Prop | Type | Default | Description |
62//! |------|------|---------|-------------|
63//! | `toggle_text` | `&'static str` | `"dropdown"` | Toggle button label |
64//! | `id` | `Option<&'static str>` | `None` | Element id |
65//! | `classes` | `Classes` | — | Additional CSS classes |
66//! | `children` | `Children` | — | Menu content |
67//!
68//! **`NavDropdownItem`:**
69//!
70//! | Prop | Type | Default | Description |
71//! |------|------|---------|-------------|
72//! | `disabled` | `bool` | `false` | Disable the item |
73//! | `classes` | `Classes` | — | Additional CSS classes |
74//! | `children` | `Children` | — | Item content |
75//!
76//! **`NavDropdownDivider`:**
77//!
78//! | Prop | Type | Default | Description |
79//! |------|------|---------|-------------|
80//! | `classes` | `Classes` | — | Additional CSS classes |
81
82use yew::prelude::*;
83
84/// Properties for the [`NavDropdown`] component.
85///
86/// | Prop | Type | Default | Description |
87/// |------|------|---------|-------------|
88/// | `toggle_text` | `&'static str` | `"dropdown"` | Toggle button label |
89/// | `id` | `Option<&'static str>` | `None` | Element id |
90/// | `classes` | `Classes` | — | Additional CSS classes |
91/// | `children` | `Children` | — | Menu content |
92#[derive(Properties, Clone, PartialEq, Debug, Default)]
93pub struct NavDropdownProps {
94    /// Additional CSS classes applied to the dropdown container.
95    #[prop_or_default]
96    pub classes: Classes,
97
98    /// Text displayed on the dropdown toggle button.
99    #[prop_or("dropdown")]
100    pub toggle_text: &'static str,
101
102    /// Optional `id` attribute for the dropdown element.
103    #[prop_or_default]
104    pub id: Option<&'static str>,
105
106    /// Content rendered inside the dropdown menu.
107    #[prop_or_default]
108    pub children: Children
109}
110
111/// Collapsible dropdown menu for grouping navigation links.
112///
113/// # CSS Classes
114///
115/// - `nav-dropdown` - Container `<li>` element
116/// - `nav-dropdown-toggle` - Toggle button
117/// - `nav-dropdown-menu` - Inner `<ul>` menu
118/// - `nav-dropdown-caret` - Caret indicator
119#[function_component]
120pub fn NavDropdown(props: &NavDropdownProps) -> Html {
121    let mut classes = props.classes.clone();
122    classes.push("nav-dropdown");
123
124    let open = use_state(|| false);
125
126    let on_toggle = {
127        let open = open.clone();
128        Callback::from(move |e: MouseEvent| {
129            e.stop_propagation();
130            open.set(!*open);
131        })
132    };
133
134    let menu_class = if *open {
135        "nav-dropdown-menu open"
136    } else {
137        "nav-dropdown-menu"
138    };
139
140    html! {
141        <li {classes} role="presentation">
142            <button
143                type="button"
144                class="nav-dropdown-toggle"
145                aria-expanded={if *open { "true" } else { "false" }}
146                aria-haspopup="true"
147                onclick={on_toggle}
148            >
149                { props.toggle_text }
150                <span class="nav-dropdown-caret">{" ▼"}</span>
151            </button>
152            <ul class={menu_class} role="menu">
153                { for props.children.iter() }
154            </ul>
155        </li>
156    }
157}
158
159/// Properties for the [`NavDropdownItem`] component.
160///
161/// | Prop | Type | Default | Description |
162/// |------|------|---------|-------------|
163/// | `disabled` | `bool` | `false` | Disable the item |
164/// | `classes` | `Classes` | — | Additional CSS classes |
165/// | `children` | `Children` | — | Item content |
166#[derive(Properties, Clone, PartialEq, Debug, Default)]
167pub struct NavDropdownItemProps {
168    /// Additional CSS classes applied to the item.
169    #[prop_or_default]
170    pub classes: Classes,
171
172    /// Whether the dropdown item is disabled.
173    #[prop_or_default]
174    pub disabled: bool,
175
176    /// Content rendered inside the item.
177    pub children: Children
178}
179
180/// A single item within a [`NavDropdown`] menu.
181///
182/// # CSS Classes
183///
184/// - `nav-dropdown-item` - Always applied
185/// - `disabled` - Applied when `disabled` is `true`
186#[function_component]
187pub fn NavDropdownItem(props: &NavDropdownItemProps) -> Html {
188    let mut classes = props.classes.clone();
189    classes.push("nav-dropdown-item");
190
191    if props.disabled {
192        classes.push("disabled");
193    }
194
195    html! {
196        <li class={classes} role="menuitem">
197            { for props.children.iter() }
198        </li>
199    }
200}
201
202/// Properties for the [`NavDropdownDivider`] component.
203///
204/// | Prop | Type | Default | Description |
205/// |------|------|---------|-------------|
206/// | `classes` | `Classes` | — | Additional CSS classes |
207#[derive(Properties, Clone, PartialEq, Eq, Debug, Default)]
208pub struct NavDropdownDividerProps {
209    /// Additional CSS classes applied to the divider.
210    #[prop_or_default]
211    pub classes: Classes
212}
213
214/// Visual separator between items in a [`NavDropdown`] menu.
215///
216/// Renders a `<li>` element with `role="separator"`.
217#[function_component]
218pub fn NavDropdownDivider(props: &NavDropdownDividerProps) -> Html {
219    let mut classes = props.classes.clone();
220    classes.push("nav-dropdown-divider");
221
222    html! {
223        <li class={classes} role="separator" />
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn nav_dropdown_props_default() {
233        let props = NavDropdownProps {
234            classes:     Classes::default(),
235            toggle_text: "Menu",
236            id:          None,
237            children:    Children::new(vec![])
238        };
239
240        assert_eq!(props.toggle_text, "Menu");
241        assert!(props.id.is_none());
242    }
243
244    #[test]
245    fn nav_dropdown_item_default() {
246        let props = NavDropdownItemProps {
247            classes:  Classes::default(),
248            disabled: false,
249            children: Children::new(vec![])
250        };
251
252        assert!(!props.disabled);
253    }
254
255    #[test]
256    fn nav_dropdown_item_disabled() {
257        let props = NavDropdownItemProps {
258            classes:  Classes::default(),
259            disabled: true,
260            children: Children::new(vec![])
261        };
262
263        assert!(props.disabled);
264    }
265
266    #[test]
267    fn nav_dropdown_divider_props() {
268        let props = NavDropdownDividerProps {
269            classes: Classes::default()
270        };
271
272        assert!(props.classes.is_empty());
273    }
274
275    #[test]
276    fn nav_dropdown_with_custom_id() {
277        let props = NavDropdownProps {
278            classes:     Classes::default(),
279            toggle_text: "Menu",
280            id:          Some("my-dropdown"),
281            children:    Children::new(vec![])
282        };
283
284        assert_eq!(props.id, Some("my-dropdown"));
285    }
286
287    #[test]
288    fn nav_dropdown_item_with_classes() {
289        let mut classes = Classes::new();
290        classes.push("custom-item");
291        let props = NavDropdownItemProps {
292            classes,
293            disabled: false,
294            children: Children::new(vec![])
295        };
296
297        assert!(props.classes.contains("custom-item"));
298    }
299
300    #[test]
301    fn nav_dropdown_disabled_item() {
302        let props = NavDropdownItemProps {
303            classes:  Classes::default(),
304            disabled: true,
305            children: Children::new(vec![])
306        };
307
308        assert!(props.disabled);
309    }
310
311    #[test]
312    fn nav_dropdown_with_children() {
313        let children = Children::new(vec![html! { <div>{ "child" }</div> }]);
314        let props = NavDropdownProps {
315            classes: Classes::default(),
316            toggle_text: "Test",
317            id: None,
318            children
319        };
320
321        assert_eq!(props.children.len(), 1);
322    }
323}