radix_leptos_primitives/components/
dropdown_menu.rs

1use crate::utils::{merge_classes, generate_id};
2use leptos::callback::Callback;
3use leptos::children::Children;
4use leptos::html;
5use leptos::prelude::*;
6use wasm_bindgen::JsCast;
7use web_sys::{KeyboardEvent, MouseEvent};
8
9#[derive(Clone, Debug, PartialEq, Default)]
10pub enum DropdownMenuSize {
11    Small,
12    #[default]
13    Medium,
14    Large,
15}
16
17#[derive(Clone, Debug, PartialEq, Default)]
18pub enum DropdownMenuItemVariant {
19    #[default]
20    Default,
21    Destructive,
22    Disabled,
23}
24
25#[component]
26pub fn DropdownMenu(
27    #[prop(optional)] class: Option<String>,
28    #[prop(optional)] style: Option<String>,
29    children: Children,
30) -> impl IntoView {
31    let (_isopen, set_isopen) = signal(false);
32    let trigger_ref = NodeRef::<html::Div>::new();
33    let content_ref = NodeRef::<html::Div>::new();
34
35    let handle_click_outside = move |e: MouseEvent| {
36        if let (Some(trigger_el), Some(content_el)) = (trigger_ref.get(), content_ref.get()) {
37            let target = e.target().unwrap();
38            let target_element = target.dyn_ref::<web_sys::Element>().unwrap();
39
40            if !trigger_el.contains(Some(target_element))
41                && !content_el.contains(Some(target_element))
42            {
43                set_isopen.set(false);
44            }
45        }
46    };
47
48    let handle_keydown = move |e: KeyboardEvent| match e.key().as_str() {
49        "Escape" => {
50            set_isopen.set(false);
51        }
52        "Enter" | " " => {
53            e.prevent_default();
54            set_isopen.update(|open| *open = !*open);
55        }
56        _ => {}
57    };
58
59    let base_classes = ["radix-dropdown-menu", "relative", "inline-block"];
60
61    let class_value = class.unwrap_or_default();
62    let classes = merge_classes(base_classes.to_vec());
63    let final_class = format!("{} {}", classes, class_value);
64
65    view! {
66        <div
67            class=final_class
68            style=style
69            data-radix-dropdown-menu=""
70            on:click=handle_click_outside
71            on:keydown=handle_keydown
72        >
73            {children()}
74        </div>
75    }
76}
77
78#[component]
79pub fn DropdownMenuTrigger(
80    #[prop(optional)] class: Option<String>,
81    #[prop(optional)] style: Option<String>,
82    #[prop(optional)] disabled: Option<bool>,
83    children: Children,
84) -> impl IntoView {
85    let handle_click = move |e: MouseEvent| {
86        e.prevent_default();
87        e.stop_propagation();
88        if !disabled.unwrap_or(false) {
89            // This would need to be connected to the parent DropdownMenu
90            // For now, we'll just log the action
91            web_sys::console::log_1(&"DropdownMenu trigger clicked".into());
92        }
93    };
94
95    let handle_keydown = move |e: KeyboardEvent| {
96        if !disabled.unwrap_or(false) {
97            match e.key().as_str() {
98                "Enter" | " " => {
99                    e.prevent_default();
100                    web_sys::console::log_1(&"DropdownMenu trigger activated".into());
101                }
102                "ArrowDown" => {
103                    e.prevent_default();
104                    web_sys::console::log_1(&"DropdownMenu trigger arrow down".into());
105                }
106                _ => {}
107            }
108        }
109    };
110
111    let base_classes = [
112        "radix-dropdown-menu-trigger",
113        "inline-flex",
114        "items-center",
115        "justify-center",
116        "rounded-md",
117        "text-sm",
118        "font-medium",
119        "transition-colors",
120        "focus-visible:outline-none",
121        "focus-visible:ring-2",
122        "focus-visible:ring-ring",
123        "focus-visible:ring-offset-2",
124        "disabled:pointer-events-none",
125        "disabled:opacity-50",
126    ];
127
128    let class_value = class.unwrap_or_default();
129    let classes = merge_classes(base_classes.to_vec());
130    let final_class = format!("{} {}", classes, class_value);
131
132    view! {
133        <div
134            class=final_class
135            style=style
136            role="button"
137            tabindex="0"
138            aria-haspopup="true"
139            aria-expanded="false"
140            data-radix-dropdown-menu-trigger=""
141            on:click=handle_click
142            on:keydown=handle_keydown
143        >
144            {children()}
145        </div>
146    }
147}
148
149#[component]
150pub fn DropdownMenuContent(
151    #[prop(optional)] class: Option<String>,
152    #[prop(optional)] style: Option<String>,
153    #[prop(optional)] align: Option<&'static str>,
154    #[prop(optional)] side: Option<&'static str>,
155    children: Children,
156) -> impl IntoView {
157    let align_class = align.unwrap_or("start");
158    let side_class = side.unwrap_or("bottom");
159
160    let base_classes = [
161        "radix-dropdown-menu-content",
162        "z-50",
163        "min-w-[8rem]",
164        "overflow-hidden",
165        "rounded-md",
166        "border",
167        "bg-popover",
168        "p-1",
169        "text-popover-foreground",
170        "shadow-md",
171        "animate-in",
172        "data-[side=bottom]:slide-in-from-top-2",
173        "data-[side=left]:slide-in-from-right-2",
174        "data-[side=right]:slide-in-from-left-2",
175        "data-[side=top]:slide-in-from-bottom-2",
176    ];
177
178    let class_value = class.unwrap_or_default();
179    let classes = merge_classes(base_classes.to_vec());
180    let final_class = format!("{} {}", classes, class_value);
181
182    view! {
183        <div
184            class=final_class
185            style=style
186            data-side=side_class
187            data-align=align_class
188            data-radix-dropdown-menu-content=""
189            role="menu"
190            aria-orientation="vertical"
191        >
192            {children()}
193        </div>
194    }
195}
196
197#[component]
198pub fn DropdownMenuItem(
199    #[prop(optional)] class: Option<String>,
200    #[prop(optional)] style: Option<String>,
201    #[prop(optional)] variant: Option<DropdownMenuItemVariant>,
202    #[prop(optional)] disabled: Option<bool>,
203    #[prop(optional)] on_click: Option<Callback<()>>,
204    children: Children,
205) -> impl IntoView {
206    let handle_click = move |e: MouseEvent| {
207        e.prevent_default();
208        e.stop_propagation();
209        if !disabled.unwrap_or(false) {
210            if let Some(callback) = on_click {
211                callback.run(());
212            }
213        }
214    };
215
216    let handle_keydown = move |e: KeyboardEvent| {
217        if !disabled.unwrap_or(false) {
218            match e.key().as_str() {
219                "Enter" | " " => {
220                    e.prevent_default();
221                    if let Some(callback) = on_click {
222                        callback.run(());
223                    }
224                }
225                "Escape" => {
226                    web_sys::console::log_1(&"DropdownMenu item escape".into());
227                }
228                _ => {}
229            }
230        }
231    };
232
233    let variant = variant.unwrap_or_default();
234    let variant_classes = match variant {
235        DropdownMenuItemVariant::Default => ["hover:bg-accent", "hover:text-accent-foreground"],
236        DropdownMenuItemVariant::Destructive => ["text-destructive", "focus:text-destructive"],
237        DropdownMenuItemVariant::Disabled => ["opacity-50", "pointer-events-none"],
238    };
239
240    let base_classes = [
241        "radix-dropdown-menu-item",
242        "relative",
243        "flex",
244        "cursor-default",
245        "select-none",
246        "items-center",
247        "rounded-sm",
248        "px-2",
249        "py-1.5",
250        "text-sm",
251        "outline-none",
252        "transition-colors",
253        "focus:bg-accent",
254        "focus:text-accent-foreground",
255        "disabled:pointer-events-none",
256        "disabled:opacity-50",
257    ];
258
259    let mut all_classes = base_classes.to_vec();
260    all_classes.extend(variant_classes);
261
262    let class_value = class.unwrap_or_default();
263    let classes = merge_classes(all_classes.iter().map(|s| s.as_ref()).collect());
264    let final_class = format!("{} {}", classes, class_value);
265
266    view! {
267        <div
268            class=final_class
269            style=style
270            role="menuitem"
271            tabindex="-1"
272            data-radix-dropdown-menu-item=""
273            on:click=handle_click
274            on:keydown=handle_keydown
275        >
276            {children()}
277        </div>
278    }
279}
280
281#[component]
282pub fn DropdownMenuSeparator(
283    #[prop(optional)] class: Option<String>,
284    #[prop(optional)] style: Option<String>,
285) -> impl IntoView {
286    let base_classes = [
287        "radix-dropdown-menu-separator",
288        "-mx-1",
289        "my-1",
290        "h-px",
291        "bg-muted",
292    ];
293
294    let class_value = class.unwrap_or_default();
295    let classes = merge_classes(base_classes.to_vec());
296    let final_class = format!("{} {}", classes, class_value);
297
298    view! {
299        <div
300            class=final_class
301            style=style
302            role="separator"
303        />
304    }
305}
306
307#[component]
308pub fn DropdownMenuLabel(
309    #[prop(optional)] class: Option<String>,
310    #[prop(optional)] style: Option<String>,
311    children: Children,
312) -> impl IntoView {
313    let base_classes = [
314        "radix-dropdown-menu-label",
315        "px-2",
316        "py-1.5",
317        "text-sm",
318        "font-semibold",
319    ];
320
321    let class_value = class.unwrap_or_default();
322    let classes = merge_classes(base_classes.to_vec());
323    let final_class = format!("{} {}", classes, class_value);
324
325    view! {
326        <div
327            class=final_class
328            style=style
329        >
330            {children()}
331        </div>
332    }
333}
334
335#[component]
336pub fn DropdownMenuCheckboxItem(
337    #[prop(optional)] class: Option<String>,
338    #[prop(optional)] style: Option<String>,
339    #[prop(optional)] checked: Option<bool>,
340    #[prop(optional)] disabled: Option<bool>,
341    #[prop(optional)] onchecked_change: Option<Callback<bool>>,
342    children: Children,
343) -> impl IntoView {
344    let (ischecked, set_ischecked) = signal(checked.unwrap_or(false));
345
346    let handle_click = move |e: MouseEvent| {
347        e.prevent_default();
348        e.stop_propagation();
349        if !disabled.unwrap_or(false) {
350            let newchecked = !ischecked.get();
351            set_ischecked.set(newchecked);
352            if let Some(callback) = onchecked_change {
353                callback.run(newchecked);
354            }
355        }
356    };
357
358    let handle_keydown = move |e: KeyboardEvent| {
359        if !disabled.unwrap_or(false) {
360            match e.key().as_str() {
361                "Enter" | " " => {
362                    e.prevent_default();
363                    let newchecked = !ischecked.get();
364                    set_ischecked.set(newchecked);
365                    if let Some(callback) = onchecked_change {
366                        callback.run(newchecked);
367                    }
368                }
369                "Escape" => {
370                    web_sys::console::log_1(&"DropdownMenu checkbox escape".into());
371                }
372                _ => {}
373            }
374        }
375    };
376
377    let base_classes = [
378        "radix-dropdown-menu-checkbox-item",
379        "relative",
380        "flex",
381        "cursor-default",
382        "select-none",
383        "items-center",
384        "rounded-sm",
385        "px-2",
386        "py-1.5",
387        "text-sm",
388        "outline-none",
389        "transition-colors",
390        "focus:bg-accent",
391        "focus:text-accent-foreground",
392        "disabled:pointer-events-none",
393        "disabled:opacity-50",
394    ];
395
396    let class_value = class.unwrap_or_default();
397    let classes = merge_classes(base_classes.to_vec());
398    let final_class = format!("{} {}", classes, class_value);
399
400    view! {
401        <div
402            class=final_class
403            style=style
404            role="menuitemcheckbox"
405            tabindex="-1"
406            aria-checked=move || ischecked.get()
407            on:click=handle_click
408            on:keydown=handle_keydown
409        >
410            <div class="flex items-center gap-2">
411                <div class="flex h-4 w-4 items-center justify-center">
412                    <div
413                        class=move || {
414                            if ischecked.get() {
415                                "h-2 w-2 bg-current"
416                            } else {
417                                ""
418                            }
419                        }
420                        style=move || {
421                            if ischecked.get() {
422                                "background-color: currentColor;"
423                            } else {
424                                ""
425                            }
426                        }
427                    />
428                </div>
429                {children()}
430            </div>
431        </div>
432    }
433}
434
435#[component]
436pub fn DropdownMenuRadioItem(
437    #[prop(optional)] class: Option<String>,
438    #[prop(optional)] style: Option<String>,
439    #[prop(optional)] value: Option<String>,
440    #[prop(optional)] checked: Option<bool>,
441    #[prop(optional)] disabled: Option<bool>,
442    #[prop(optional)] on_value_change: Option<Callback<String>>,
443    children: Children,
444) -> impl IntoView {
445    let (ischecked, set_ischecked) = signal(checked.unwrap_or(false));
446    let value = value.unwrap_or_default();
447
448    let handle_click = {
449        let value = value.clone();
450        move |e: MouseEvent| {
451            e.prevent_default();
452            e.stop_propagation();
453            if !disabled.unwrap_or(false) {
454                set_ischecked.set(true);
455                if let Some(callback) = on_value_change {
456                    let value_clone = value.clone();
457                    callback.run(value_clone);
458                }
459            }
460        }
461    };
462
463    let handle_keydown = {
464        let value = value.clone();
465        move |e: KeyboardEvent| {
466            if !disabled.unwrap_or(false) {
467                match e.key().as_str() {
468                    "Enter" | " " => {
469                        e.prevent_default();
470                        set_ischecked.set(true);
471                        if let Some(callback) = on_value_change {
472                            let value_clone = value.clone();
473                            callback.run(value_clone);
474                        }
475                    }
476                    "Escape" => {
477                        web_sys::console::log_1(&"DropdownMenu radio escape".into());
478                    }
479                    _ => {}
480                }
481            }
482        }
483    };
484
485    let base_classes = [
486        "radix-dropdown-menu-radio-item",
487        "relative",
488        "flex",
489        "cursor-default",
490        "select-none",
491        "items-center",
492        "rounded-sm",
493        "px-2",
494        "py-1.5",
495        "text-sm",
496        "outline-none",
497        "transition-colors",
498        "focus:bg-accent",
499        "focus:text-accent-foreground",
500        "disabled:pointer-events-none",
501        "disabled:opacity-50",
502    ];
503
504    let class_value = class.unwrap_or_default();
505    let classes = merge_classes(base_classes.to_vec());
506    let final_class = format!("{} {}", classes, class_value);
507
508    view! {
509        <div
510            class=final_class
511            style=style
512            role="menuitemradio"
513            tabindex="-1"
514            aria-checked=move || ischecked.get()
515            on:click=handle_click
516            on:keydown=handle_keydown
517        >
518            <div class="flex items-center gap-2">
519                <div class="flex h-4 w-4 items-center justify-center">
520                    <div
521                        class=move || {
522                            if ischecked.get() {
523                                "h-2 w-2 rounded-full bg-current"
524                            } else {
525                                ""
526                            }
527                        }
528                        style=move || {
529                            if ischecked.get() {
530                                "background-color: currentColor;"
531                            } else {
532                                ""
533                            }
534                        }
535                    />
536                </div>
537                {children()}
538            </div>
539        </div>
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use crate::{DropdownMenuItemVariant, DropdownMenuSize};
546    use wasm_bindgen_test::*;
547
548    wasm_bindgen_test_configure!(run_in_browser);
549
550    #[test]
551    fn test_dropdown_menu_creation() {
552        // Test that the component can be created
553    }
554
555    #[test]
556    fn test_dropdown_menu_with_class() {
557        // Test that the component can be created with class
558    }
559
560    #[test]
561    fn test_dropdown_menu_with_style() {
562        // Test that the component can be created with style
563    }
564
565    #[test]
566    fn test_dropdown_menu_trigger_creation() {
567        // Test that the trigger component can be created
568    }
569
570    #[test]
571    fn test_dropdown_menu_triggerdisabled() {
572        // Test that the trigger component can be created disabled
573    }
574
575    #[test]
576    fn test_dropdown_menu_content_creation() {
577        // Test that the content component can be created
578    }
579
580    #[test]
581    fn test_dropdown_menu_content_with_align() {
582        // Test that the content component can be created with align
583    }
584
585    #[test]
586    fn test_dropdown_menu_content_with_side() {
587        // Test that the content component can be created with side
588    }
589
590    #[test]
591    fn test_dropdown_menu_item_creation() {
592        // Test that the item component can be created
593    }
594
595    #[test]
596    fn test_dropdown_menu_itemdisabled() {
597        // Test that the item component can be created disabled
598    }
599
600    #[test]
601    fn test_dropdown_menu_item_variants() {
602        let variants = [
603            DropdownMenuItemVariant::Default,
604            DropdownMenuItemVariant::Destructive,
605            DropdownMenuItemVariant::Disabled,
606        ];
607
608        for variant in variants {
609            // Test that each variant can be created
610        }
611    }
612
613    #[test]
614    fn test_dropdown_menu_item_with_callback() {
615        // Test that the item component can be created with callback
616    }
617
618    #[test]
619    fn test_dropdown_menu_separator_creation() {
620        // Test that the separator component can be created
621    }
622
623    #[test]
624    fn test_dropdown_menu_separator_with_class() {
625        // Test that the separator component can be created with class
626    }
627
628    #[test]
629    fn test_dropdown_menu_label_creation() {
630        // Test that the label component can be created
631    }
632
633    #[test]
634    fn test_dropdown_menu_checkbox_item_creation() {
635        // Test that the checkbox item component can be created
636    }
637
638    #[test]
639    fn test_dropdown_menu_checkbox_itemchecked() {
640        // Test that the checkbox item component can be created checked
641    }
642
643    #[test]
644    fn test_dropdown_menu_checkbox_itemdisabled() {
645        // Test that the checkbox item component can be created disabled
646    }
647
648    #[test]
649    fn test_dropdown_menu_checkbox_item_with_callback() {
650        // Test that the checkbox item component can be created with callback
651    }
652
653    #[test]
654    fn test_dropdown_menu_radio_item_creation() {
655        // Test that the radio item component can be created
656    }
657
658    #[test]
659    fn test_dropdown_menu_radio_item_with_value() {
660        // Test that the radio item component can be created with value
661    }
662
663    #[test]
664    fn test_dropdown_menu_radio_itemchecked() {
665        // Test that the radio item component can be created checked
666    }
667
668    #[test]
669    fn test_dropdown_menu_radio_itemdisabled() {
670        // Test that the radio item component can be created disabled
671    }
672
673    #[test]
674    fn test_dropdown_menu_radio_item_with_callback() {
675        // Test that the radio item component can be created with callback
676    }
677
678    #[test]
679    fn test_dropdown_menu_size_default() {
680        let size = DropdownMenuSize::default();
681        assert_eq!(size, DropdownMenuSize::Medium);
682    }
683
684    #[test]
685    fn test_dropdown_menu_item_variant_default() {
686        let variant = DropdownMenuItemVariant::default();
687        assert_eq!(variant, DropdownMenuItemVariant::Default);
688    }
689
690    #[test]
691    fn test_merge_classes_empty() {
692        let result = crate::utils::merge_classes(Vec::new());
693        assert_eq!(result, "");
694    }
695
696    #[test]
697    fn test_merge_classes_single() {
698        let result = crate::utils::merge_classes(vec!["class1"]);
699        assert_eq!(result, "class1");
700    }
701
702    #[test]
703    fn test_merge_classes_multiple() {
704        let result = crate::utils::merge_classes(vec!["class1", "class2", "class3"]);
705        assert_eq!(result, "class1 class2 class3");
706    }
707
708    #[test]
709    fn test_merge_classes_with_empty() {
710        let result = crate::utils::merge_classes(vec!["class1", "", "class3"]);
711        assert_eq!(result, "class1 class3");
712    }
713
714    // Property-based tests
715    #[test]
716    fn test_dropdown_menu_property_based() {
717        use proptest::prelude::*;
718
719        proptest!(|(____class in ".*", __style in ".*")| {
720            // Test that the component can be created with various class and style values
721
722        });
723    }
724
725    #[test]
726    fn test_dropdown_menu_trigger_property_based() {
727        use proptest::prelude::*;
728
729        proptest!(|(____class in ".*", __style in ".*", _disabled: bool)| {
730            // Test that the trigger component can be created with various properties
731
732        });
733    }
734
735    #[test]
736    fn test_dropdown_menu_item_property_based() {
737        use proptest::prelude::*;
738
739        proptest!(|(____class in ".*", __style in ".*", _disabled: bool)| {
740            // Test that the item component can be created with various properties
741
742        });
743    }
744
745    #[test]
746    fn test_dropdown_menu_checkbox_item_property_based() {
747        use proptest::prelude::*;
748
749        proptest!(|(____class in ".*", __style in ".*", _checked: bool, _disabled: bool)| {
750            // Test that the checkbox item component can be created with various properties
751
752        });
753    }
754
755    #[test]
756    fn test_dropdown_menu_radio_item_property_based() {
757        use proptest::prelude::*;
758
759        proptest!(|(____class in ".*", __style in ".*", __value in ".*", _checked: bool, _disabled: bool)| {
760            // Test that the radio item component can be created with various properties
761
762        });
763    }
764}