Skip to main content

yew_nav_link/components/
dropdown.rs

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