radix_leptos_primitives/components/
navigation_menu.rs

1use crate::utils::{merge_classes, generate_id};
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 {
169            let _: () = view! { <></> };
170            ().into_any()
171        };
172    }
173
174    let class = merge_classes(vec![
175        "navigation-menu-content",
176        class.as_deref().unwrap_or(""),
177    ]);
178
179    view! {
180        <div
181            class=class
182            style=style
183            role="menu"
184            aria-hidden="false"
185        >
186            {children.map(|c| c())}
187        </div>
188    }
189    .into_any()
190}
191
192/// Navigation Menu Link component
193#[component]
194pub fn NavigationMenuLink(
195    #[prop(optional)] class: Option<String>,
196    #[prop(optional)] style: Option<String>,
197    #[prop(optional)] children: Option<Children>,
198    #[prop(optional)] href: Option<String>,
199    #[prop(optional)] disabled: Option<bool>,
200    #[prop(optional)] active: Option<bool>,
201    #[prop(optional)] on_click: Option<Callback<()>>,
202) -> impl IntoView {
203    let disabled = disabled.unwrap_or(false);
204    let active = active.unwrap_or(false);
205
206    let class = merge_classes(vec!["navigation-menu-link", class.as_deref().unwrap_or("")]);
207
208    let handle_click = move |_| {
209        if !disabled {
210            if let Some(on_click) = on_click {
211                on_click.run(());
212            }
213        }
214    };
215
216    if let Some(href) = href {
217        view! {
218            <a
219                class=class
220                style=style
221                href=href
222                on:click=handle_click
223            >
224                {children.map(|c| c())}
225            </a>
226        }
227        .into_any()
228    } else {
229        view! {
230            <button
231                class=class
232                style=style
233                on:click=handle_click
234            >
235                {children.map(|c| c())}
236            </button>
237        }
238        .into_any()
239    }
240}
241
242/// Navigation Menu Separator component
243#[component]
244pub fn NavigationMenuSeparator(
245    #[prop(optional)] class: Option<String>,
246    #[prop(optional)] style: Option<String>,
247) -> impl IntoView {
248    let class = merge_classes(vec![
249        "navigation-menu-separator",
250        class.as_deref().unwrap_or(""),
251    ]);
252
253    view! {
254        <div
255            class=class
256            style=style
257            role="separator"
258            aria-orientation="horizontal"
259        />
260    }
261}
262
263/// Navigation Menu Orientation enum
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
265pub enum NavigationMenuOrientation {
266    #[default]
267    Horizontal,
268    Vertical,
269}
270
271impl NavigationMenuOrientation {
272    pub fn to_class(&self) -> &'static str {
273        match self {
274            NavigationMenuOrientation::Horizontal => "horizontal",
275            NavigationMenuOrientation::Vertical => "vertical",
276        }
277    }
278
279    pub fn to_aria(&self) -> &'static str {
280        match self {
281            NavigationMenuOrientation::Horizontal => "horizontal",
282            NavigationMenuOrientation::Vertical => "vertical",
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289
290    use crate::NavigationMenuOrientation;
291    use wasm_bindgen_test::*;
292
293    wasm_bindgen_test_configure!(run_in_browser);
294
295    // Navigation Menu Tests
296    #[test]
297    fn test_navigation_menu_creation() {
298        // Test that the component can be created
299    }
300
301    #[test]
302    fn test_navigation_menu_with_class() {
303        // Test that the component can be created with class
304    }
305
306    #[test]
307    fn test_navigation_menu_with_style() {
308        // Test that the component can be created with style
309    }
310
311    #[test]
312    fn test_navigation_menu_horizontal_orientation() {
313        // Test horizontal orientation
314    }
315
316    #[test]
317    fn test_navigation_menu_vertical_orientation() {
318        // Test vertical orientation
319    }
320
321    #[test]
322    fn test_navigation_menu_with_value() {
323        // Test with controlled value
324    }
325
326    #[test]
327    fn test_navigation_menu_with_default_value() {
328        // Test with default value
329    }
330
331    #[test]
332    fn test_navigation_menu_value_change_callback() {
333        // Test value change callback
334    }
335
336    // Navigation Menu List Tests
337    #[test]
338    fn test_navigation_menu_list_creation() {
339        // Test that the component can be created
340    }
341
342    #[test]
343    fn test_navigation_menu_list_with_class() {
344        // Test that the component can be created with class
345    }
346
347    #[test]
348    fn test_navigation_menu_list_with_style() {
349        // Test that the component can be created with style
350    }
351
352    // Navigation Menu Item Tests
353    #[test]
354    fn test_navigation_menu_item_creation() {
355        // Test that the component can be created
356    }
357
358    #[test]
359    fn test_navigation_menu_item_with_class() {
360        // Test that the component can be created with class
361    }
362
363    #[test]
364    fn test_navigation_menu_item_with_style() {
365        // Test that the component can be created with style
366    }
367
368    #[test]
369    fn test_navigation_menu_item_with_value() {
370        // Test with value
371    }
372
373    #[test]
374    fn test_navigation_menu_itemdisabled() {
375        // Test disabled state
376    }
377
378    #[test]
379    fn test_navigation_menu_item_on_select() {
380        // Test on_select callback
381    }
382
383    // Navigation Menu Trigger Tests
384    #[test]
385    fn test_navigation_menu_trigger_creation() {
386        // Test that the component can be created
387    }
388
389    #[test]
390    fn test_navigation_menu_trigger_with_class() {
391        // Test that the component can be created with class
392    }
393
394    #[test]
395    fn test_navigation_menu_trigger_with_style() {
396        // Test that the component can be created with style
397    }
398
399    #[test]
400    fn test_navigation_menu_triggerdisabled() {
401        // Test disabled state
402    }
403
404    #[test]
405    fn test_navigation_menu_trigger_on_click() {
406        // Test on_click callback
407    }
408
409    // Navigation Menu Content Tests
410    #[test]
411    fn test_navigation_menu_content_creation() {
412        // Test that the component can be created
413    }
414
415    #[test]
416    fn test_navigation_menu_content_with_class() {
417        // Test that the component can be created with class
418    }
419
420    #[test]
421    fn test_navigation_menu_content_with_style() {
422        // Test that the component can be created with style
423    }
424
425    #[test]
426    fn test_navigation_menu_contentvisible() {
427        // Test visible state
428    }
429
430    #[test]
431    fn test_navigation_menu_content_hidden() {
432        // Test hidden state
433    }
434
435    // Navigation Menu Link Tests
436    #[test]
437    fn test_navigation_menu_link_creation() {
438        // Test that the component can be created
439    }
440
441    #[test]
442    fn test_navigation_menu_link_with_class() {
443        // Test that the component can be created with class
444    }
445
446    #[test]
447    fn test_navigation_menu_link_with_style() {
448        // Test that the component can be created with style
449    }
450
451    #[test]
452    fn test_navigation_menu_link_with_href() {
453        // Test with href (anchor)
454    }
455
456    #[test]
457    fn test_navigation_menu_link_without_href() {
458        // Test without href (button)
459    }
460
461    #[test]
462    fn test_navigation_menu_linkdisabled() {
463        // Test disabled state
464    }
465
466    #[test]
467    fn test_navigation_menu_link_active() {
468        // Test active state
469    }
470
471    #[test]
472    fn test_navigation_menu_link_on_click() {
473        // Test on_click callback
474    }
475
476    // Navigation Menu Separator Tests
477    #[test]
478    fn test_navigation_menu_separator_creation() {
479        // Test that the component can be created
480    }
481
482    #[test]
483    fn test_navigation_menu_separator_with_class() {
484        // Test that the component can be created with class
485    }
486
487    #[test]
488    fn test_navigation_menu_separator_with_style() {
489        // Test that the component can be created with style
490    }
491
492    // Navigation Menu Orientation Tests
493    #[test]
494    fn test_navigation_menu_orientation_default() {
495        let orientation = NavigationMenuOrientation::default();
496        assert_eq!(orientation, NavigationMenuOrientation::Horizontal);
497    }
498
499    #[test]
500    fn test_navigation_menu_orientation_horizontal() {
501        let orientation = NavigationMenuOrientation::Horizontal;
502        assert_eq!(orientation.to_class(), "horizontal");
503        assert_eq!(orientation.to_aria(), "horizontal");
504    }
505
506    #[test]
507    fn test_navigation_menu_orientation_vertical() {
508        let orientation = NavigationMenuOrientation::Vertical;
509        assert_eq!(orientation.to_class(), "vertical");
510        assert_eq!(orientation.to_aria(), "vertical");
511    }
512
513    // Helper Function Tests
514    #[test]
515    fn test_merge_classes_empty() {
516        let result = crate::utils::merge_classes(Vec::new());
517        assert_eq!(result, "");
518    }
519
520    #[test]
521    fn test_merge_classes_single() {
522        let result = crate::utils::merge_classes(vec!["class1"]);
523        assert_eq!(result, "class1");
524    }
525
526    #[test]
527    fn test_merge_classes_multiple() {
528        let result = crate::utils::merge_classes(vec!["class1", "class2", "class3"]);
529        assert_eq!(result, "class1 class2 class3");
530    }
531
532    #[test]
533    fn test_merge_classes_with_empty() {
534        let result = crate::utils::merge_classes(vec!["class1", "", "class3"]);
535        assert_eq!(result, "class1 class3");
536    }
537
538    // Property-based tests
539    #[test]
540    fn test_navigation_menu_property_based() {
541        use proptest::prelude::*;
542
543        proptest!(|(____class in ".*", __style in ".*")| {
544            // Test that the component can be created with various class and style values
545
546        });
547    }
548
549    #[test]
550    fn test_navigation_menu_list_property_based() {
551        use proptest::prelude::*;
552
553        proptest!(|(____class in ".*", __style in ".*")| {
554            // Test that the component can be created with various class and style values
555
556        });
557    }
558
559    #[test]
560    fn test_navigation_menu_item_property_based() {
561        use proptest::prelude::*;
562
563        proptest!(|(____class in ".*", __style in ".*", __value in ".*")| {
564            // Test that the component can be created with various prop values
565
566        });
567    }
568
569    #[test]
570    fn test_navigation_menu_trigger_property_based() {
571        use proptest::prelude::*;
572
573        proptest!(|(____class in ".*", __style in ".*")| {
574            // Test that the component can be created with various class and style values
575
576        });
577    }
578
579    #[test]
580    fn test_navigation_menu_content_property_based() {
581        use proptest::prelude::*;
582
583        proptest!(|(____class in ".*", __style in ".*")| {
584            // Test that the component can be created with various class and style values
585
586        });
587    }
588
589    #[test]
590    fn test_navigation_menu_link_property_based() {
591        use proptest::prelude::*;
592
593        proptest!(|(____class in ".*", __style in ".*", _href in ".*")| {
594            // Test that the component can be created with various prop values
595
596        });
597    }
598
599    #[test]
600    fn test_navigation_menu_separator_property_based() {
601        use proptest::prelude::*;
602
603        proptest!(|(____class in ".*", __style in ".*")| {
604            // Test that the component can be created with various class and style values
605
606        });
607    }
608}