radix_leptos_primitives/components/
dropdown_menu.rs

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