radix_leptos_primitives/components/
navigation_menu.rs

1use crate::utils::merge_classes;
2use leptos::callback::Callback;
3use leptos::children::Children;
4use leptos::prelude::*;
5
6/// Navigation Menu component for main navigation
7///
8/// Provides accessible navigation with keyboard support and ARIA attributes
9#[component]
10pub fn NavigationMenu(
11    #[prop(optional)] class: Option<String>,
12    #[prop(optional)] style: Option<String>,
13    #[prop(optional)] children: Option<Children>,
14    #[prop(optional)] orientation: Option<NavigationMenuOrientation>,
15    #[prop(optional)] default_value: Option<String>,
16    #[prop(optional)] value: Option<ReadSignal<String>>,
17    #[prop(optional)] on_value_change: Option<Callback<String>>,
18) -> impl IntoView {
19    let orientation = orientation.unwrap_or_default();
20    let (current_value, setcurrent_value) = signal(
21        value
22            .map(|v| v.get())
23            .unwrap_or_else(|| default_value.unwrap_or_default()),
24    );
25
26    // Handle value changes
27    if let Some(on_change) = on_value_change {
28        Effect::new(move |_| {
29            on_change.run(current_value.get());
30        });
31    }
32
33    // Handle external value changes
34    if let Some(external_value) = value {
35        Effect::new(move |_| {
36            setcurrent_value.set(external_value.get());
37        });
38    }
39
40    let class = merge_classes(vec![
41        "navigation-menu",
42        &orientation.to_class(),
43        class.as_deref().unwrap_or(""),
44    ]);
45
46    view! {
47        <nav
48            class=class
49            style=style
50            role="navigation"
51            aria-orientation=orientation.to_aria()
52        >
53            {children.map(|c| c())}
54        </nav>
55    }
56}
57
58/// Navigation Menu List component
59#[component]
60pub fn NavigationMenuList(
61    #[prop(optional)] class: Option<String>,
62    #[prop(optional)] style: Option<String>,
63    #[prop(optional)] children: Option<Children>,
64) -> impl IntoView {
65    let class = merge_classes(vec!["navigation-menu-list", class.as_deref().unwrap_or("")]);
66
67    view! {
68        <ul class=class style=style role="menubar">
69            {children.map(|c| c())}
70        </ul>
71    }
72}
73
74/// Navigation Menu Item component
75#[component]
76pub fn NavigationMenuItem(
77    #[prop(optional)] class: Option<String>,
78    #[prop(optional)] style: Option<String>,
79    #[prop(optional)] children: Option<Children>,
80    #[prop(optional)] value: Option<String>,
81    #[prop(optional)] disabled: Option<bool>,
82    #[prop(optional)] on_select: Option<Callback<()>>,
83) -> impl IntoView {
84    let disabled = disabled.unwrap_or(false);
85    let value = value.unwrap_or_default();
86
87    let class = merge_classes(vec!["navigation-menu-item"]);
88
89    let handle_keydown = move |ev: web_sys::KeyboardEvent| {
90        if !disabled && (ev.key() == "Enter" || ev.key() == " ") {
91            ev.prevent_default();
92            if let Some(on_select) = on_select {
93                on_select.run(());
94            }
95        }
96    };
97
98    view! {
99        <li
100            class=class
101            style=style
102            role="none"
103        >
104            <button
105                role="menuitem"
106            >
107            </button>
108        </li>
109    }
110}
111
112/// Navigation Menu Trigger component
113#[component]
114pub fn NavigationMenuTrigger(
115    #[prop(optional)] class: Option<String>,
116    #[prop(optional)] style: Option<String>,
117    #[prop(optional)] children: Option<Children>,
118    #[prop(optional)] disabled: Option<bool>,
119    #[prop(optional)] on_click: Option<Callback<()>>,
120) -> impl IntoView {
121    let disabled = disabled.unwrap_or(false);
122
123    let class = merge_classes(vec!["navigation-menu-trigger"]);
124
125    let handle_keydown = move |ev: web_sys::KeyboardEvent| {
126        if !disabled && (ev.key() == "Enter" || ev.key() == " ") {
127            ev.prevent_default();
128            if let Some(on_click) = on_click {
129                on_click.run(());
130            }
131        }
132    };
133
134    let handle_click = move |_| {
135        if !disabled {
136            if let Some(on_click) = on_click {
137                on_click.run(());
138            }
139        }
140    };
141
142    view! {
143        <button
144            class=class
145            style=style
146            disabled=disabled
147            on:click=handle_click
148            on:keydown=handle_keydown
149            aria-haspopup="true"
150            aria-expanded="false"
151        >
152            {children.map(|c| c())}
153        </button>
154    }
155}
156
157/// Navigation Menu Content component
158#[component]
159pub fn NavigationMenuContent(
160    #[prop(optional)] class: Option<String>,
161    #[prop(optional)] style: Option<String>,
162    #[prop(optional)] children: Option<Children>,
163    #[prop(optional)] visible: Option<ReadSignal<bool>>,
164) -> impl IntoView {
165    let visible = visible.map(|v| v.get()).unwrap_or(true);
166
167    if !visible {
168        return view! { <></> }.into_any();
169    }
170
171    let class = merge_classes(vec![
172        "navigation-menu-content",
173        class.as_deref().unwrap_or(""),
174    ]);
175
176    view! {
177        <div
178            class=class
179            style=style
180            role="menu"
181            aria-hidden="false"
182        >
183            {children.map(|c| c())}
184        </div>
185    }
186    .into_any()
187}
188
189/// Navigation Menu Link component
190#[component]
191pub fn NavigationMenuLink(
192    #[prop(optional)] class: Option<String>,
193    #[prop(optional)] style: Option<String>,
194    #[prop(optional)] children: Option<Children>,
195    #[prop(optional)] href: Option<String>,
196    #[prop(optional)] disabled: Option<bool>,
197    #[prop(optional)] active: Option<bool>,
198    #[prop(optional)] on_click: Option<Callback<()>>,
199) -> impl IntoView {
200    let disabled = disabled.unwrap_or(false);
201    let active = active.unwrap_or(false);
202
203    let class = merge_classes(vec!["navigation-menu-link", class.as_deref().unwrap_or("")]);
204
205    let handle_click = move |_| {
206        if !disabled {
207            if let Some(on_click) = on_click {
208                on_click.run(());
209            }
210        }
211    };
212
213    if let Some(href) = href {
214        view! {
215            <a
216                class=class
217                style=style
218                href=href
219                on:click=handle_click
220            >
221                {children.map(|c| c())}
222            </a>
223        }
224        .into_any()
225    } else {
226        view! {
227            <button
228                class=class
229                style=style
230                on:click=handle_click
231            >
232                {children.map(|c| c())}
233            </button>
234        }
235        .into_any()
236    }
237}
238
239/// Navigation Menu Separator component
240#[component]
241pub fn NavigationMenuSeparator(
242    #[prop(optional)] class: Option<String>,
243    #[prop(optional)] style: Option<String>,
244) -> impl IntoView {
245    let class = merge_classes(vec![
246        "navigation-menu-separator",
247        class.as_deref().unwrap_or(""),
248    ]);
249
250    view! {
251        <div
252            class=class
253            style=style
254            role="separator"
255            aria-orientation="horizontal"
256        />
257    }
258}
259
260/// Navigation Menu Orientation enum
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
262pub enum NavigationMenuOrientation {
263    #[default]
264    Horizontal,
265    Vertical,
266}
267
268impl NavigationMenuOrientation {
269    pub fn to_class(&self) -> &'static str {
270        match self {
271            NavigationMenuOrientation::Horizontal => "horizontal",
272            NavigationMenuOrientation::Vertical => "vertical",
273        }
274    }
275
276    pub fn to_aria(&self) -> &'static str {
277        match self {
278            NavigationMenuOrientation::Horizontal => "horizontal",
279            NavigationMenuOrientation::Vertical => "vertical",
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use crate::utils::merge_classes;
287    use crate::NavigationMenuOrientation;
288    use wasm_bindgen_test::*;
289
290    wasm_bindgen_test_configure!(run_in_browser);
291
292    // Navigation Menu Tests
293    #[test]
294    fn test_navigation_menu_creation() {
295        // Test that the component can be created
296    }
297
298    #[test]
299    fn test_navigation_menu_with_class() {
300        // Test that the component can be created with class
301    }
302
303    #[test]
304    fn test_navigation_menu_with_style() {
305        // Test that the component can be created with style
306    }
307
308    #[test]
309    fn test_navigation_menu_horizontal_orientation() {
310        // Test horizontal orientation
311    }
312
313    #[test]
314    fn test_navigation_menu_vertical_orientation() {
315        // Test vertical orientation
316    }
317
318    #[test]
319    fn test_navigation_menu_with_value() {
320        // Test with controlled value
321    }
322
323    #[test]
324    fn test_navigation_menu_with_default_value() {
325        // Test with default value
326    }
327
328    #[test]
329    fn test_navigation_menu_value_change_callback() {
330        // Test value change callback
331    }
332
333    // Navigation Menu List Tests
334    #[test]
335    fn test_navigation_menu_list_creation() {
336        // Test that the component can be created
337    }
338
339    #[test]
340    fn test_navigation_menu_list_with_class() {
341        // Test that the component can be created with class
342    }
343
344    #[test]
345    fn test_navigation_menu_list_with_style() {
346        // Test that the component can be created with style
347    }
348
349    // Navigation Menu Item Tests
350    #[test]
351    fn test_navigation_menu_item_creation() {
352        // Test that the component can be created
353    }
354
355    #[test]
356    fn test_navigation_menu_item_with_class() {
357        // Test that the component can be created with class
358    }
359
360    #[test]
361    fn test_navigation_menu_item_with_style() {
362        // Test that the component can be created with style
363    }
364
365    #[test]
366    fn test_navigation_menu_item_with_value() {
367        // Test with value
368    }
369
370    #[test]
371    fn test_navigation_menu_itemdisabled() {
372        // Test disabled state
373    }
374
375    #[test]
376    fn test_navigation_menu_item_on_select() {
377        // Test on_select callback
378    }
379
380    // Navigation Menu Trigger Tests
381    #[test]
382    fn test_navigation_menu_trigger_creation() {
383        // Test that the component can be created
384    }
385
386    #[test]
387    fn test_navigation_menu_trigger_with_class() {
388        // Test that the component can be created with class
389    }
390
391    #[test]
392    fn test_navigation_menu_trigger_with_style() {
393        // Test that the component can be created with style
394    }
395
396    #[test]
397    fn test_navigation_menu_triggerdisabled() {
398        // Test disabled state
399    }
400
401    #[test]
402    fn test_navigation_menu_trigger_on_click() {
403        // Test on_click callback
404    }
405
406    // Navigation Menu Content Tests
407    #[test]
408    fn test_navigation_menu_content_creation() {
409        // Test that the component can be created
410    }
411
412    #[test]
413    fn test_navigation_menu_content_with_class() {
414        // Test that the component can be created with class
415    }
416
417    #[test]
418    fn test_navigation_menu_content_with_style() {
419        // Test that the component can be created with style
420    }
421
422    #[test]
423    fn test_navigation_menu_contentvisible() {
424        // Test visible state
425    }
426
427    #[test]
428    fn test_navigation_menu_content_hidden() {
429        // Test hidden state
430    }
431
432    // Navigation Menu Link Tests
433    #[test]
434    fn test_navigation_menu_link_creation() {
435        // Test that the component can be created
436    }
437
438    #[test]
439    fn test_navigation_menu_link_with_class() {
440        // Test that the component can be created with class
441    }
442
443    #[test]
444    fn test_navigation_menu_link_with_style() {
445        // Test that the component can be created with style
446    }
447
448    #[test]
449    fn test_navigation_menu_link_with_href() {
450        // Test with href (anchor)
451    }
452
453    #[test]
454    fn test_navigation_menu_link_without_href() {
455        // Test without href (button)
456    }
457
458    #[test]
459    fn test_navigation_menu_linkdisabled() {
460        // Test disabled state
461    }
462
463    #[test]
464    fn test_navigation_menu_link_active() {
465        // Test active state
466    }
467
468    #[test]
469    fn test_navigation_menu_link_on_click() {
470        // Test on_click callback
471    }
472
473    // Navigation Menu Separator Tests
474    #[test]
475    fn test_navigation_menu_separator_creation() {
476        // Test that the component can be created
477    }
478
479    #[test]
480    fn test_navigation_menu_separator_with_class() {
481        // Test that the component can be created with class
482    }
483
484    #[test]
485    fn test_navigation_menu_separator_with_style() {
486        // Test that the component can be created with style
487    }
488
489    // Navigation Menu Orientation Tests
490    #[test]
491    fn test_navigation_menu_orientation_default() {
492        let orientation = NavigationMenuOrientation::default();
493        assert_eq!(orientation, NavigationMenuOrientation::Horizontal);
494    }
495
496    #[test]
497    fn test_navigation_menu_orientation_horizontal() {
498        let orientation = NavigationMenuOrientation::Horizontal;
499        assert_eq!(orientation.to_class(), "horizontal");
500        assert_eq!(orientation.to_aria(), "horizontal");
501    }
502
503    #[test]
504    fn test_navigation_menu_orientation_vertical() {
505        let orientation = NavigationMenuOrientation::Vertical;
506        assert_eq!(orientation.to_class(), "vertical");
507        assert_eq!(orientation.to_aria(), "vertical");
508    }
509
510    // Helper Function Tests
511    #[test]
512    fn test_merge_classes_empty() {
513        let result = crate::utils::merge_classes(Vec::new());
514        assert_eq!(result, "");
515    }
516
517    #[test]
518    fn test_merge_classes_single() {
519        let result = crate::utils::merge_classes(vec!["class1"]);
520        assert_eq!(result, "class1");
521    }
522
523    #[test]
524    fn test_merge_classes_multiple() {
525        let result = crate::utils::merge_classes(vec!["class1", "class2", "class3"]);
526        assert_eq!(result, "class1 class2 class3");
527    }
528
529    #[test]
530    fn test_merge_classes_with_empty() {
531        let result = crate::utils::merge_classes(vec!["class1", "", "class3"]);
532        assert_eq!(result, "class1 class3");
533    }
534
535    // Property-based tests
536    #[test]
537    fn test_navigation_menu_property_based() {
538        use proptest::prelude::*;
539
540        proptest!(|(____class in ".*", __style in ".*")| {
541            // Test that the component can be created with various class and style values
542
543        });
544    }
545
546    #[test]
547    fn test_navigation_menu_list_property_based() {
548        use proptest::prelude::*;
549
550        proptest!(|(____class in ".*", __style in ".*")| {
551            // Test that the component can be created with various class and style values
552
553        });
554    }
555
556    #[test]
557    fn test_navigation_menu_item_property_based() {
558        use proptest::prelude::*;
559
560        proptest!(|(____class in ".*", __style in ".*", __value in ".*")| {
561            // Test that the component can be created with various prop values
562
563        });
564    }
565
566    #[test]
567    fn test_navigation_menu_trigger_property_based() {
568        use proptest::prelude::*;
569
570        proptest!(|(____class in ".*", __style in ".*")| {
571            // Test that the component can be created with various class and style values
572
573        });
574    }
575
576    #[test]
577    fn test_navigation_menu_content_property_based() {
578        use proptest::prelude::*;
579
580        proptest!(|(____class in ".*", __style in ".*")| {
581            // Test that the component can be created with various class and style values
582
583        });
584    }
585
586    #[test]
587    fn test_navigation_menu_link_property_based() {
588        use proptest::prelude::*;
589
590        proptest!(|(____class in ".*", __style in ".*", _href in ".*")| {
591            // Test that the component can be created with various prop values
592
593        });
594    }
595
596    #[test]
597    fn test_navigation_menu_separator_property_based() {
598        use proptest::prelude::*;
599
600        proptest!(|(____class in ".*", __style in ".*")| {
601            // Test that the component can be created with various class and style values
602
603        });
604    }
605}