/ libs / ui / src / components / dropdown.rs
dropdown.rs
  1  use crate::{use_id_or, use_unique_id};
  2  use dioxus::html::GlobalAttributesExtension;
  3  use dioxus::prelude::*;
  4  pub use dioxus_primitives::dropdown_menu::DropdownMenuTrigger as DropdownTrigger;
  5  use dioxus_primitives::dropdown_menu::{DropdownMenu, DropdownMenuContent, DropdownMenuItem};
  6  use lucide_dioxus::Check;
  7  
  8  // Define a context struct for radio groups
  9  #[derive(Clone, PartialEq)]
 10  struct RadioGroupContext<T: Clone + PartialEq + 'static> {
 11      value: Signal<T>,
 12      on_change: Callback<T>,
 13  }
 14  
 15  /// Dropdown size options
 16  #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
 17  pub enum DropdownSize {
 18      Small,
 19      #[default]
 20      Medium,
 21      Large,
 22  }
 23  
 24  // Note: DropdownSize is kept for backward compatibility but no longer used internally
 25  
 26  // DropdownProps - Props for the main Dropdown component
 27  #[derive(Props, Clone, PartialEq)]
 28  pub struct DropdownProps {
 29      /// Whether the dropdown should be open by default
 30      #[props(default = false)]
 31      default_open: bool,
 32  
 33      /// Optional ID for the dropdown
 34      #[props(default)]
 35      id: Option<String>,
 36  
 37      /// Whether the dropdown is disabled
 38      #[props(default)]
 39      disabled: bool,
 40  
 41      /// Accessible label for the dropdown
 42      #[props(default)]
 43      aria_label: Option<String>,
 44  
 45      #[props(extends = GlobalAttributes)]
 46      attributes: Vec<Attribute>,
 47  
 48      children: Element,
 49  }
 50  
 51  // Dropdown Trigger Props
 52  #[derive(Props, Clone, PartialEq)]
 53  pub struct DropdownTriggerProps {
 54      /// Whether the trigger is disabled
 55      #[props(default)]
 56      disabled: bool,
 57  
 58      /// Optional ID for the trigger
 59      #[props(default)]
 60      id: Option<String>,
 61  
 62      /// Optional aria-label for the trigger (for accessibility)
 63      #[props(default)]
 64      aria_label: Option<String>,
 65  
 66      #[props(extends = GlobalAttributes)]
 67      attributes: Vec<Attribute>,
 68  
 69      children: Element,
 70  }
 71  
 72  // Dropdown Content Props
 73  #[derive(Props, Clone, PartialEq)]
 74  pub struct DropdownContentProps {
 75      /// Alignment of the dropdown menu
 76      #[props(default = String::from("start"))]
 77      align: String,
 78  
 79      /// Width of the dropdown menu (use class names like "w-56")
 80      #[props(default = String::from("w-56"))]
 81      width: String,
 82  
 83      /// Optional ID for the content
 84      #[props(default)]
 85      id: Option<String>,
 86  
 87      #[props(extends = GlobalAttributes)]
 88      attributes: Vec<Attribute>,
 89  
 90      /// Children elements
 91      children: Element,
 92  }
 93  
 94  // Dropdown Item Props
 95  #[derive(Props, Clone, PartialEq)]
 96  pub struct DropdownItemProps<T: Clone + PartialEq + 'static> {
 97      /// The value of the item
 98      value: ReadSignal<T>,
 99  
100      /// The index of the item
101      #[props(default)]
102      index: usize,
103  
104      /// Whether the item is disabled
105      #[props(default)]
106      disabled: bool,
107  
108      /// Whether the item is destructive (red)
109      #[props(default)]
110      destructive: bool,
111  
112      /// Optional icon to display before the item text
113      #[props(default)]
114      icon: Option<Element>,
115  
116      /// Optional ID for the item
117      #[props(default)]
118      id: Option<String>,
119  
120      /// The callback function that will be called when the item is selected. The value of the item will be passed as an argument.
121      #[props(default)]
122      pub on_select: Callback<T>,
123  
124      #[props(extends = GlobalAttributes)]
125      attributes: Vec<Attribute>,
126  
127      /// Children elements
128      children: Element,
129  }
130  
131  #[component]
132  pub fn Dropdown(props: DropdownProps) -> Element {
133      // Generate unique ID if not provided
134      let dropdown_id = use_unique_id();
135      let props_id = use_signal(|| props.id);
136      let id_value = use_id_or(dropdown_id, props_id.into());
137      let mut is_open = use_signal::<Option<bool>>(|| Some(false));
138  
139      // Determine base classes for dropdown
140      let dropdown_classes = vec![
141          "relative inline-block text-left",
142          if props.disabled {
143              "opacity-50 pointer-events-none"
144          } else {
145              ""
146          },
147      ]
148      .into_iter()
149      .filter(|s| !s.is_empty())
150      .collect::<Vec<_>>()
151      .join(" ");
152  
153      let disabled_val = props.disabled;
154  
155      rsx! {
156          DropdownMenu {
157              open: is_open,
158              on_open_change: move |new_open| is_open.set(Some(new_open)),
159              class: dropdown_classes,
160              id: id_value,
161              default_open: props.default_open,
162              // Note: DropdownMenuTrigger doesn't have a disabled prop, using class and aria attributes instead
163              "aria-disabled": if disabled_val { "true" } else { "false" },
164              aria_label: props.aria_label.clone(),
165              div {
166                  tabindex: 0,    // Make this focusable
167                  {props.children}
168              }
169          }
170      }
171  }
172  
173  #[component]
174  pub fn DropdownContent(props: DropdownContentProps) -> Element {
175      // Generate unique ID if not provided
176      let content_id = use_unique_id();
177      let props_id = use_signal(|| props.id);
178      let id_value = use_id_or(content_id, props_id.into());
179  
180      // Alignment classes
181      let align_class = match props.align.as_str() {
182          "end" => "right-0 origin-top-right",
183          "center" => "left-1/2 -translate-x-1/2 origin-top",
184          _ => "left-0 origin-top-left", // Default to start
185      };
186  
187      // Content classes
188      let content_classes = [
189          "absolute mt-2 rounded bg-popover shadow-md",
190          "border border-border p-1 text-popover-foreground",
191          "animate-in fade-in-80 data-[side=bottom]:slide-in-from-top-2",
192          "data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
193          "data-[side=top]:slide-in-from-bottom-2 z-50",
194          align_class,
195          &props.width,
196      ]
197      .join(" ");
198  
199      rsx! {
200          DropdownMenuContent {
201              class: content_classes,
202              id: id_value,
203  
204              {props.children}
205          }
206      }
207  }
208  
209  // Dropdown Label Props
210  #[derive(Props, Clone, PartialEq)]
211  pub struct DropdownLabelProps {
212      /// Optional ID for the label
213      #[props(default)]
214      id: ReadSignal<Option<String>>,
215  
216      #[props(extends = GlobalAttributes)]
217      attributes: Vec<Attribute>,
218  
219      /// Children elements
220      children: Element,
221  }
222  
223  #[component]
224  pub fn DropdownLabel(props: DropdownLabelProps) -> Element {
225      // Generate unique ID if not provided
226      let label_id = use_unique_id();
227      let id_value = use_id_or(label_id, props.id);
228  
229      // Label classes
230      let label_classes = "px-2 py-1.5 text-xs font-semibold text-foreground/80";
231  
232      rsx! {
233          div {
234              class: label_classes,
235              id: id_value,
236              ..props.attributes,
237              {props.children}
238          }
239      }
240  }
241  
242  // Dropdown Separator Props
243  #[derive(Props, Clone, PartialEq)]
244  pub struct DropdownSeparatorProps {
245      /// Optional ID for the separator
246      #[props(default)]
247      id: ReadSignal<Option<String>>,
248  
249      #[props(extends = GlobalAttributes)]
250      attributes: Vec<Attribute>,
251  }
252  
253  #[component]
254  pub fn DropdownSeparator(props: DropdownSeparatorProps) -> Element {
255      // Generate unique ID if not provided
256      let separator_id = use_unique_id();
257      let id_value = use_id_or(separator_id, props.id);
258  
259      // Separator classes
260      let separator_classes = "h-px my-1 bg-muted";
261  
262      rsx! {
263          div {
264              class: separator_classes,
265              id: id_value,
266              role: "separator",
267              aria_orientation: "horizontal",
268              ..props.attributes,
269          }
270      }
271  }
272  
273  // Dropdown Checkbox Item Props
274  #[derive(Props, Clone, PartialEq)]
275  pub struct DropdownCheckboxItemProps {
276      /// The index of the item
277      #[props(default)]
278      index: ReadSignal<usize>,
279  
280      /// Whether the checkbox is checked
281      #[props(default)]
282      checked: bool,
283  
284      /// Whether the item is disabled
285      #[props(default)]
286      disabled: bool,
287  
288      /// Optional ID for the item
289      #[props(default)]
290      id: Option<String>,
291  
292      /// Callback when the item is selected
293      #[props(default)]
294      on_change: Option<Callback<bool>>,
295  
296      #[props(extends = GlobalAttributes)]
297      attributes: Vec<Attribute>,
298  
299      /// Children elements
300      children: Element,
301  }
302  
303  #[component]
304  pub fn DropdownCheckboxItem(props: DropdownCheckboxItemProps) -> Element {
305      // Generate unique ID if not provided
306      let item_id = use_unique_id();
307      let props_id = use_signal(|| props.id);
308      let id_value = use_id_or(item_id, props_id.into());
309  
310      // Handle change event
311      let handle_change = move |_: bool| {
312          if let Some(handler) = &props.on_change {
313              handler.call(!props.checked);
314          }
315      };
316  
317      // Determine item classes
318      let item_classes = vec![
319          // Base classes
320          "relative flex cursor-pointer select-none items-center rounded px-2 py-1.5",
321          "text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
322          "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
323          // State classes
324          if props.disabled {
325              "pointer-events-none opacity-50"
326          } else {
327              "hover:bg-accent hover:text-accent-foreground"
328          },
329      ]
330      .into_iter()
331      .filter(|s| !s.is_empty())
332      .collect::<Vec<_>>()
333      .join(" ");
334  
335      rsx! {
336          DropdownMenuItem::<bool> {
337              class: item_classes,
338              id: id_value,
339              value: props.checked,
340              index: props.index,
341              disabled: props.disabled,
342              on_select: handle_change,
343  
344              // Checkbox indicator
345              span {
346                  class: "mr-2 h-4 w-4 flex items-center justify-center border-none",
347                  aria_hidden: "true",
348  
349                  if props.checked {
350                      Check {
351                          class: "h-4 w-4 text-current",
352                      }
353                  }
354              }
355  
356              {props.children}
357          }
358      }
359  }
360  
361  // Dropdown Radio Group Props
362  #[derive(Props, Clone, PartialEq)]
363  pub struct DropdownRadioGroupProps {
364      /// The value of the selected radio item
365      #[props(default)]
366      value: Signal<String>,
367  
368      /// Optional ID for the radio group
369      #[props(default)]
370      id: ReadSignal<Option<String>>,
371  
372      /// Callback when the selection changes
373      #[props(default)]
374      on_value_change: Option<EventHandler<String>>,
375  
376      #[props(extends = GlobalAttributes)]
377      attributes: Vec<Attribute>,
378  
379      /// Children elements
380      children: Element,
381  }
382  
383  #[component]
384  pub fn DropdownRadioGroup(props: DropdownRadioGroupProps) -> Element {
385      // Generate unique ID if not provided
386      let group_id = use_unique_id();
387      let id_value = use_id_or(group_id, props.id);
388  
389      // Create a context with value signal and change handler
390      if let Some(handler) = &props.on_value_change {
391          let context = RadioGroupContext {
392              value: props.value,
393              on_change: *handler,
394          };
395          provide_context(context);
396      }
397  
398      rsx! {
399          div {
400              id: id_value,
401              role: "radiogroup",
402              class: "dropdown-radio-group",
403  
404              {props.children}
405          }
406      }
407  }
408  
409  // Dropdown Radio Item Props
410  #[derive(Props, Clone, PartialEq)]
411  pub struct DropdownRadioItemProps<T: Clone + PartialEq + 'static> {
412      /// The value of the radio item
413      value: T,
414  
415      /// The index of the item
416      #[props(default)]
417      index: usize,
418  
419      /// Whether the item is disabled
420      #[props(default)]
421      disabled: bool,
422  
423      /// Optional ID for the item
424      #[props(default)]
425      id: Option<String>,
426  
427      #[props(extends = GlobalAttributes)]
428      attributes: Vec<Attribute>,
429  
430      /// Children elements
431      children: Element,
432  }
433  
434  #[component]
435  pub fn DropdownRadioItem<T: Clone + PartialEq + 'static>(
436      props: DropdownRadioItemProps<T>,
437  ) -> Element {
438      // Generate unique ID if not provided
439      let item_id = use_unique_id();
440      let props_id = use_signal(|| props.id);
441      let id_value = use_id_or(item_id, props_id.into());
442  
443      // Get the radio group context if available
444      let context = use_context::<RadioGroupContext<T>>();
445  
446      // Check if this item is selected based on context
447      let is_selected: bool = *context.value.read() == props.value;
448  
449      // Determine item classes
450      let item_classes = vec![
451          // Base classes
452          "relative flex cursor-pointer select-none items-center rounded px-2 py-1.5",
453          "text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
454          "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
455          // State classes
456          if props.disabled {
457              "pointer-events-none opacity-50"
458          } else {
459              "hover:bg-accent hover:text-accent-foreground"
460          },
461      ]
462      .into_iter()
463      .filter(|s| !s.is_empty())
464      .collect::<Vec<_>>()
465      .join(" ");
466  
467      let handle_select = move |value: T| {
468          context.on_change.call(value);
469      };
470  
471      rsx! {
472          DropdownMenuItem::<T> {
473              class: item_classes,
474              id: id_value,
475              value: ReadSignal::new(Signal::new(props.value)),
476              index: ReadSignal::new(Signal::new(props.index)),
477              disabled: props.disabled,
478              on_select: handle_select,
479  
480              // Radio indicator
481              span {
482                  class: "mr-2 h-3.5 w-3.5 flex items-center justify-center rounded-full border",
483                  aria_hidden: "true",
484  
485                  // The dot will be shown when this item is selected
486                  span {
487                      class: "h-1.5 w-1.5 rounded-full bg-current",
488                      style: if is_selected { "opacity: 1" } else { "opacity: 0" },
489                  }
490              }
491  
492              {props.children}
493          }
494      }
495  }
496  
497  #[component]
498  pub fn DropdownItem<T: Clone + PartialEq + 'static>(props: DropdownItemProps<T>) -> Element {
499      // Generate unique ID if not provided
500      let item_id = use_unique_id();
501      let props_id = use_signal(|| props.id);
502      let id_value = use_id_or(item_id, props_id.into());
503  
504      // Determine item classes
505      let item_classes = vec![
506          // Base classes
507          "relative flex cursor-pointer select-none items-center rounded px-2 py-1.5",
508          "text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
509          "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
510          "disabled:pointer-events-none disabled:opacity-50 hover:bg-secondary hover:text-accent-foreground",
511  
512          // Destructive style
513          if props.destructive { "text-destructive focus:text-destructive" } else { "" },
514      ]
515      .into_iter()
516      .filter(|s| !s.is_empty())
517      .collect::<Vec<_>>()
518      .join(" ");
519  
520      let index_val = props.index;
521      let disabled_val = props.disabled;
522  
523      let icon_element = if let Some(icon) = &props.icon {
524          rsx! {
525              span {
526                  class: "mr-2",
527                  aria_hidden: "true",
528                  {icon.clone()}
529              }
530          }
531      } else {
532          rsx! {}
533      };
534  
535      rsx! {
536          DropdownMenuItem::<T> {
537              class: item_classes,
538              id: id_value,
539              value: props.value,
540              index: ReadSignal::<usize>::new(Signal::new(index_val)),
541              disabled: disabled_val,
542              on_select: props.on_select,
543  
544              {icon_element}
545  
546              {props.children}
547          }
548      }
549  }