/ libs / primitives / src / dropdown_menu.rs
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  }