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 }