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}