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}