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}