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 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 assert!(true);
571 }
572
573 #[test]
574 fn test_dropdown_menu_with_class() {
575 assert!(true);
577 }
578
579 #[test]
580 fn test_dropdown_menu_with_style() {
581 assert!(true);
583 }
584
585 #[test]
586 fn test_dropdown_menu_trigger_creation() {
587 assert!(true);
589 }
590
591 #[test]
592 fn test_dropdown_menu_trigger_disabled() {
593 assert!(true);
595 }
596
597 #[test]
598 fn test_dropdown_menu_content_creation() {
599 assert!(true);
601 }
602
603 #[test]
604 fn test_dropdown_menu_content_with_align() {
605 assert!(true);
607 }
608
609 #[test]
610 fn test_dropdown_menu_content_with_side() {
611 assert!(true);
613 }
614
615 #[test]
616 fn test_dropdown_menu_item_creation() {
617 assert!(true);
619 }
620
621 #[test]
622 fn test_dropdown_menu_item_disabled() {
623 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 assert!(true);
638 }
639 assert!(true);
640 }
641
642 #[test]
643 fn test_dropdown_menu_item_with_callback() {
644 assert!(true);
646 }
647
648 #[test]
649 fn test_dropdown_menu_separator_creation() {
650 assert!(true);
652 }
653
654 #[test]
655 fn test_dropdown_menu_separator_with_class() {
656 assert!(true);
658 }
659
660 #[test]
661 fn test_dropdown_menu_label_creation() {
662 assert!(true);
664 }
665
666 #[test]
667 fn test_dropdown_menu_checkbox_item_creation() {
668 assert!(true);
670 }
671
672 #[test]
673 fn test_dropdown_menu_checkbox_item_checked() {
674 assert!(true);
676 }
677
678 #[test]
679 fn test_dropdown_menu_checkbox_item_disabled() {
680 assert!(true);
682 }
683
684 #[test]
685 fn test_dropdown_menu_checkbox_item_with_callback() {
686 assert!(true);
688 }
689
690 #[test]
691 fn test_dropdown_menu_radio_item_creation() {
692 assert!(true);
694 }
695
696 #[test]
697 fn test_dropdown_menu_radio_item_with_value() {
698 assert!(true);
700 }
701
702 #[test]
703 fn test_dropdown_menu_radio_item_checked() {
704 assert!(true);
706 }
707
708 #[test]
709 fn test_dropdown_menu_radio_item_disabled() {
710 assert!(true);
712 }
713
714 #[test]
715 fn test_dropdown_menu_radio_item_with_callback() {
716 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 #[test]
758 fn test_dropdown_menu_property_based() {
759 use proptest::prelude::*;
760
761 proptest!(|(class in ".*", style in ".*")| {
762 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 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 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 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 assert!(true);
804 });
805 }
806}