dropdown_menu.rs
1 //! Defines the [`DropdownMenu`] component and its subcomponents. 2 3 use std::rc::Rc; 4 5 use crate::{ 6 focus::{use_focus_controlled_item, use_focus_provider, FocusState}, 7 merge_attributes, use_animated_open, use_controlled, use_id_or, use_unique_id, 8 }; 9 use dioxus::prelude::*; 10 use dioxus_attributes::attributes; 11 12 #[derive(Clone, Copy)] 13 struct DropdownMenuContext { 14 // State 15 open: Memo<bool>, 16 set_open: Callback<bool>, 17 disabled: ReadSignal<bool>, 18 19 // Focus state 20 focus: FocusState, 21 22 // Unique ID for the trigger button 23 trigger_id: Signal<String>, 24 } 25 26 /// The props for the [`DropdownMenu`] component 27 #[derive(Props, Clone, PartialEq)] 28 pub struct DropdownMenuProps { 29 /// Whether the dropdown menu is open. If not provided, the component will be uncontrolled and use `default_open`. 30 pub open: ReadSignal<Option<bool>>, 31 32 /// Default open state if the component is not controlled. 33 #[props(default)] 34 pub default_open: bool, 35 36 /// Callback when the open state changes. This is called when the dropdown menu is opened or closed. 37 #[props(default)] 38 pub on_open_change: Callback<bool>, 39 40 /// Whether the dropdown menu is disabled. If true, the menu will not open and items will not be selectable. 41 #[props(default)] 42 pub disabled: ReadSignal<bool>, 43 44 /// Whether focus should loop around when reaching the end. 45 #[props(default = ReadSignal::new(Signal::new(true)))] 46 pub roving_loop: ReadSignal<bool>, 47 48 /// Additional attributes to apply to the dropdown menu element. 49 #[props(extends = GlobalAttributes)] 50 pub attributes: Vec<Attribute>, 51 52 /// The children of the dropdown menu, which should include a [`DropdownMenuTrigger`] and a [`DropdownMenuContent`]. 53 pub children: Element, 54 } 55 56 /// # DropdownMenu 57 /// 58 /// The `DropdownMenu` component is a container for a [`DropdownMenuContent`] component activated by a [`DropdownMenuTrigger`] component. 59 /// 60 /// ## Example 61 /// ```rust 62 /// use dioxus::prelude::*; 63 /// use dioxus_primitives::dropdown_menu::{ 64 /// DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, 65 /// }; 66 /// #[component] 67 /// fn Demo() -> Element { 68 /// rsx! { 69 /// DropdownMenu { default_open: false, 70 /// DropdownMenuTrigger { "Open Menu" } 71 /// DropdownMenuContent { 72 /// DropdownMenuItem::<String> { 73 /// value: "edit".to_string(), 74 /// index: 0usize, 75 /// on_select: move |value| { 76 /// tracing::info!("Selected: {}", value); 77 /// }, 78 /// "Edit" 79 /// } 80 /// DropdownMenuItem::<String> { 81 /// value: "undo".to_string(), 82 /// index: 1usize, 83 /// disabled: true, 84 /// on_select: move |value| { 85 /// tracing::info!("Selected: {}", value); 86 /// }, 87 /// "Undo" 88 /// } 89 /// } 90 /// } 91 /// } 92 /// } 93 /// ``` 94 /// 95 /// ## Styling 96 /// 97 /// The [`DropdownMenu`] component defines the following data attributes you can use to control styling: 98 /// - `data-state`: Indicates the current state of the dropdown menu. values are `open` or `closed`. 99 /// - `data-disabled`: Indicates if the dropdown menu is disabled. values are `true` or `false`. 100 #[component] 101 pub fn DropdownMenu(props: DropdownMenuProps) -> Element { 102 let (open, set_open) = use_controlled(props.open, props.default_open, props.on_open_change); 103 104 let disabled = props.disabled; 105 let trigger_id = use_unique_id(); 106 let focus = use_focus_provider(props.roving_loop); 107 let mut ctx = use_context_provider(|| DropdownMenuContext { 108 open, 109 set_open, 110 disabled, 111 focus, 112 trigger_id, 113 }); 114 115 use_effect(move || { 116 let focused = focus.any_focused(); 117 if *ctx.open.peek() != focused { 118 (ctx.set_open)(focused); 119 } 120 }); 121 122 // Handle escape key to close the menu 123 let handle_keydown = move |event: Event<KeyboardData>| { 124 if disabled() { 125 return; 126 } 127 match event.key() { 128 Key::Enter => { 129 let new_open = !(ctx.open)(); 130 ctx.set_open.call(new_open); 131 } 132 Key::Escape => ctx.set_open.call(false), 133 Key::ArrowDown => { 134 ctx.focus.focus_next(); 135 } 136 Key::ArrowUp => { 137 if open() { 138 ctx.focus.focus_prev(); 139 } 140 } 141 Key::Home => ctx.focus.focus_first(), 142 Key::End => ctx.focus.focus_last(), 143 _ => return, 144 } 145 event.prevent_default(); 146 }; 147 148 rsx! { 149 div { 150 "data-state": if open() { "open" } else { "closed" }, 151 "data-disabled": (props.disabled)(), 152 onkeydown: handle_keydown, 153 ..props.attributes, 154 {props.children} 155 } 156 } 157 } 158 159 /// The props for the [`DropdownMenuTrigger`] component 160 #[derive(Props, Clone, PartialEq)] 161 pub struct DropdownMenuTriggerProps { 162 /// Render the trigger element as a custom component/element. 163 #[props(default)] 164 pub r#as: Option<Callback<Vec<Attribute>, Element>>, 165 166 /// Additional attributes to apply to the trigger element. 167 #[props(extends = GlobalAttributes)] 168 pub attributes: Vec<Attribute>, 169 /// The children of the trigger 170 pub children: Element, 171 } 172 173 /// # DropdownMenuTrigger 174 /// 175 /// The trigger button for the parent [`DropdownMenu`]. This button toggles the visibility of the [`DropdownMenuContent`]. 176 /// 177 /// This must be used inside a [`DropdownMenu`] component. 178 /// 179 /// ## Example 180 /// ```rust 181 /// use dioxus::prelude::*; 182 /// use dioxus_primitives::dropdown_menu::{ 183 /// DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, 184 /// }; 185 /// #[component] 186 /// fn Demo() -> Element { 187 /// rsx! { 188 /// DropdownMenu { default_open: false, 189 /// DropdownMenuTrigger { "Open Menu" } 190 /// DropdownMenuContent { 191 /// DropdownMenuItem::<String> { 192 /// value: "edit".to_string(), 193 /// index: 0usize, 194 /// on_select: move |value| { 195 /// tracing::info!("Selected: {}", value); 196 /// }, 197 /// "Edit" 198 /// } 199 /// DropdownMenuItem::<String> { 200 /// value: "undo".to_string(), 201 /// index: 1usize, 202 /// disabled: true, 203 /// on_select: move |value| { 204 /// tracing::info!("Selected: {}", value); 205 /// }, 206 /// "Undo" 207 /// } 208 /// } 209 /// } 210 /// } 211 /// } 212 /// ``` 213 /// 214 /// ## Styling 215 /// 216 /// The [`DropdownMenuTrigger`] component defines the following data attributes you can use to control styling: 217 /// - `data-state`: Indicates the current state of the dropdown menu. values are `open` or `closed`. 218 /// - `data-disabled`: Indicates if the dropdown menu is disabled. values are `true` or `false`. 219 #[component] 220 pub fn DropdownMenuTrigger(props: DropdownMenuTriggerProps) -> Element { 221 let mut ctx: DropdownMenuContext = use_context(); 222 let mut element = use_signal(|| None::<Rc<MountedData>>); 223 224 let open = ctx.open; 225 let disabled = ctx.disabled; 226 let data_state = if open() { "open" } else { "closed" }; 227 228 let base = attributes!(button { 229 id: ctx.trigger_id, 230 r#type: "button", 231 "data-state": data_state, 232 "data-disabled": disabled, 233 disabled: disabled, 234 aria_expanded: open, 235 aria_haspopup: "listbox", 236 onmounted: move |e: MountedEvent| { 237 element.set(Some(e.data())); 238 }, 239 onclick: move |_| { 240 if disabled() { 241 return; 242 } 243 244 let new_open = !open(); 245 ctx.set_open.call(new_open); 246 247 // Focus the element on click. Safari does not do this automatically. 248 // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#clicking_and_focus 249 if let Some(data) = element() { 250 spawn(async move { 251 _ = data.set_focus(true).await; 252 }); 253 } 254 }, 255 onblur: move |_| { 256 if !ctx.focus.any_focused() { 257 ctx.focus.blur(); 258 } 259 }, 260 }); 261 let merged = merge_attributes(vec![base, props.attributes]); 262 263 if let Some(dynamic) = props.r#as { 264 dynamic.call(merged) 265 } else { 266 rsx! { 267 button { 268 ..merged, 269 {props.children} 270 } 271 } 272 } 273 } 274 275 /// The props for the [`DropdownMenuContent`] component 276 #[derive(Props, Clone, PartialEq)] 277 pub struct DropdownMenuContentProps { 278 /// The ID of the dropdown menu content element. If not provided, a unique ID will be generated. 279 pub id: ReadSignal<Option<String>>, 280 /// Additional attributes to apply to the dropdown menu content element. 281 #[props(extends = GlobalAttributes)] 282 pub attributes: Vec<Attribute>, 283 /// The children of the dropdown menu content, which should include one or more [`DropdownMenuItem`] components. 284 pub children: Element, 285 } 286 287 /// # DropdownMenuTrigger 288 /// 289 /// The contents of a [`DropdownMenu`]. The component will only be rendered when the parent [`DropdownMenu`] is open (as control by the [`DropdownMenuTrigger`]). 290 /// 291 /// This must be used inside a [`DropdownMenu`] component. 292 /// 293 /// ## Example 294 /// ```rust 295 /// use dioxus::prelude::*; 296 /// use dioxus_primitives::dropdown_menu::{ 297 /// DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, 298 /// }; 299 /// #[component] 300 /// fn Demo() -> Element { 301 /// rsx! { 302 /// DropdownMenu { default_open: false, 303 /// DropdownMenuTrigger { "Open Menu" } 304 /// DropdownMenuContent { 305 /// DropdownMenuItem::<String> { 306 /// value: "edit".to_string(), 307 /// index: 0usize, 308 /// on_select: move |value| { 309 /// tracing::info!("Selected: {}", value); 310 /// }, 311 /// "Edit" 312 /// } 313 /// DropdownMenuItem::<String> { 314 /// value: "undo".to_string(), 315 /// index: 1usize, 316 /// disabled: true, 317 /// on_select: move |value| { 318 /// tracing::info!("Selected: {}", value); 319 /// }, 320 /// "Undo" 321 /// } 322 /// } 323 /// } 324 /// } 325 /// } 326 /// ``` 327 /// 328 /// ## Styling 329 /// 330 /// The [`DropdownMenuContent`] component defines the following data attributes you can use to control styling: 331 /// - `data-state`: Indicates the current state of the dropdown menu. values are `open` or `closed`. 332 #[component] 333 pub fn DropdownMenuContent(props: DropdownMenuContentProps) -> Element { 334 let ctx: DropdownMenuContext = use_context(); 335 336 let unique_id = use_unique_id(); 337 let id = use_id_or(unique_id, props.id); 338 let render = use_animated_open(id, ctx.open); 339 340 rsx! { 341 if render() { 342 div { 343 id, 344 role: "listbox", 345 aria_labelledby: "{ctx.trigger_id}", 346 "data-state": if (ctx.open)() { "open" } else { "closed" }, 347 onpointerdown: move |event| { 348 // The user is starting a click inside the dropdown menu. 349 // Prevent the blur event from occurring during pointerdown, 350 // to keep the dropdown menu open until pointerup happens, 351 // thus enabling onclick/onselect events to fire. 352 event.prevent_default(); 353 event.stop_propagation(); 354 }, 355 ..props.attributes, 356 {props.children} 357 } 358 } 359 } 360 } 361 362 /// The props for the [`DropdownMenuItem`] component 363 #[derive(Props, Clone, PartialEq)] 364 pub struct DropdownMenuItemProps<T: Clone + PartialEq + 'static> { 365 /// The value of the item, which will be passed to the `on_select` callback when clicked. 366 pub value: ReadSignal<T>, 367 /// The index of the item within the [`DropdownMenuContent`]. This is used to order the items for keyboard navigation. 368 pub index: ReadSignal<usize>, 369 370 /// Whether the item is disabled. If true, the item will not be clickable and will not respond to keyboard events. 371 /// Defaults to false. 372 #[props(default)] 373 pub disabled: ReadSignal<bool>, 374 375 /// The callback function that will be called when the item is selected. The value of the item will be passed as an argument. 376 #[props(default)] 377 pub on_select: Callback<T>, 378 379 /// Additional attributes to apply to the item element. 380 #[props(extends = GlobalAttributes)] 381 pub attributes: Vec<Attribute>, 382 /// The children of the item, which will be rendered inside the item element. 383 pub children: Element, 384 } 385 386 /// # DropdownMenuTrigger 387 /// 388 /// An item within a [`DropdownMenuContent`]. This component represents an individual selectable item in the dropdown menu. 389 /// 390 /// This must be used inside a [`DropdownMenu`] component. 391 /// 392 /// ## Example 393 /// ```rust 394 /// use dioxus::prelude::*; 395 /// use dioxus_primitives::dropdown_menu::{ 396 /// DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, 397 /// }; 398 /// #[component] 399 /// fn Demo() -> Element { 400 /// rsx! { 401 /// DropdownMenu { default_open: false, 402 /// DropdownMenuTrigger { "Open Menu" } 403 /// DropdownMenuContent { 404 /// DropdownMenuItem::<String> { 405 /// value: "edit".to_string(), 406 /// index: 0usize, 407 /// on_select: move |value| { 408 /// tracing::info!("Selected: {}", value); 409 /// }, 410 /// "Edit" 411 /// } 412 /// DropdownMenuItem::<String> { 413 /// value: "undo".to_string(), 414 /// index: 1usize, 415 /// disabled: true, 416 /// on_select: move |value| { 417 /// tracing::info!("Selected: {}", value); 418 /// }, 419 /// "Undo" 420 /// } 421 /// } 422 /// } 423 /// } 424 /// } 425 /// ``` 426 /// 427 /// ## Styling 428 /// 429 /// The [`DropdownMenuItem`] component defines the following data attributes you can use to control styling: 430 /// - `data-disabled`: Indicates whether the item is disabled. Values are `true` or `false`. 431 #[component] 432 pub fn DropdownMenuItem<T: Clone + PartialEq + 'static>( 433 props: DropdownMenuItemProps<T>, 434 ) -> Element { 435 let mut ctx: DropdownMenuContext = use_context(); 436 437 let disabled = move || (ctx.disabled)() || (props.disabled)(); 438 let focused = move || ctx.focus.is_focused((props.index)()); 439 440 let onmounted = use_focus_controlled_item(props.index); 441 442 rsx! { 443 div { 444 role: "option", 445 "data-disabled": disabled(), 446 tabindex: if focused() { "0" } else { "-1" }, 447 448 onclick: move |e: Event<MouseData>| { 449 e.stop_propagation(); 450 if !disabled() { 451 props.on_select.call((props.value)()); 452 ctx.set_open.call(false); 453 } 454 }, 455 456 onkeydown: move |event: Event<KeyboardData>| { 457 if event.key() == Key::Enter || event.key() == Key::Character(" ".to_string()) { 458 if !disabled() { 459 props.on_select.call((props.value)()); 460 ctx.set_open.call(false); 461 } 462 event.prevent_default(); 463 event.stop_propagation(); 464 } 465 }, 466 467 onmounted, 468 469 onblur: move |_| { 470 if focused() { 471 ctx.focus.blur(); 472 } 473 }, 474 475 ..props.attributes, 476 {props.children} 477 } 478 } 479 }