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 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 }
554
555 #[test]
556 fn test_dropdown_menu_with_class() {
557 }
559
560 #[test]
561 fn test_dropdown_menu_with_style() {
562 }
564
565 #[test]
566 fn test_dropdown_menu_trigger_creation() {
567 }
569
570 #[test]
571 fn test_dropdown_menu_triggerdisabled() {
572 }
574
575 #[test]
576 fn test_dropdown_menu_content_creation() {
577 }
579
580 #[test]
581 fn test_dropdown_menu_content_with_align() {
582 }
584
585 #[test]
586 fn test_dropdown_menu_content_with_side() {
587 }
589
590 #[test]
591 fn test_dropdown_menu_item_creation() {
592 }
594
595 #[test]
596 fn test_dropdown_menu_itemdisabled() {
597 }
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 }
611 }
612
613 #[test]
614 fn test_dropdown_menu_item_with_callback() {
615 }
617
618 #[test]
619 fn test_dropdown_menu_separator_creation() {
620 }
622
623 #[test]
624 fn test_dropdown_menu_separator_with_class() {
625 }
627
628 #[test]
629 fn test_dropdown_menu_label_creation() {
630 }
632
633 #[test]
634 fn test_dropdown_menu_checkbox_item_creation() {
635 }
637
638 #[test]
639 fn test_dropdown_menu_checkbox_itemchecked() {
640 }
642
643 #[test]
644 fn test_dropdown_menu_checkbox_itemdisabled() {
645 }
647
648 #[test]
649 fn test_dropdown_menu_checkbox_item_with_callback() {
650 }
652
653 #[test]
654 fn test_dropdown_menu_radio_item_creation() {
655 }
657
658 #[test]
659 fn test_dropdown_menu_radio_item_with_value() {
660 }
662
663 #[test]
664 fn test_dropdown_menu_radio_itemchecked() {
665 }
667
668 #[test]
669 fn test_dropdown_menu_radio_itemdisabled() {
670 }
672
673 #[test]
674 fn test_dropdown_menu_radio_item_with_callback() {
675 }
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 #[test]
716 fn test_dropdown_menu_property_based() {
717 use proptest::prelude::*;
718
719 proptest!(|(____class in ".*", __style in ".*")| {
720 });
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 });
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 });
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 });
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 });
763 }
764}