/ libs / primitives / src / context_menu.rs
context_menu.rs
  1  //! Defines the [`ContextMenu`] component and its subcomponents, which provide a context menu interface.
  2  
  3  use crate::{
  4      focus::{use_focus_controlled_item, use_focus_provider, FocusState},
  5      use_animated_open, use_controlled, use_effect_cleanup, use_id_or, use_unique_id,
  6  };
  7  use dioxus::prelude::*;
  8  
  9  #[derive(Clone, Copy)]
 10  struct ContextMenuCtx {
 11      // State
 12      open: Memo<bool>,
 13      set_open: Callback<bool>,
 14      disabled: ReadSignal<bool>,
 15  
 16      // Position of the context menu
 17      position: Signal<(i32, i32)>,
 18  
 19      // Focus state
 20      focus: FocusState,
 21  }
 22  
 23  /// The props for the [`ContextMenu`] component.
 24  #[derive(Props, Clone, PartialEq)]
 25  pub struct ContextMenuProps {
 26      /// Whether the context menu is disabled
 27      #[props(default = ReadSignal::new(Signal::new(false)))]
 28      pub disabled: ReadSignal<bool>,
 29  
 30      /// Whether the context menu is open
 31      pub open: ReadSignal<Option<bool>>,
 32  
 33      /// Default open state
 34      #[props(default)]
 35      pub default_open: bool,
 36  
 37      /// Callback when open state changes
 38      #[props(default)]
 39      pub on_open_change: Callback<bool>,
 40  
 41      /// Whether focus should loop around when reaching the end.
 42      #[props(default = ReadSignal::new(Signal::new(true)))]
 43      pub roving_loop: ReadSignal<bool>,
 44  
 45      /// Additional attributes for the context menu element.
 46      #[props(extends = GlobalAttributes)]
 47      pub attributes: Vec<Attribute>,
 48  
 49      /// The children of the context menu component.
 50      pub children: Element,
 51  }
 52  
 53  /// # ContextMenu
 54  ///
 55  /// The [`ContextMenu`] component is a container that can be used to create a context menu. You can
 56  /// use the [`ContextMenuTrigger`] to open the menu on a right-click, and the [`ContextMenuContent`] to define the menu item.
 57  ///
 58  /// ## Example
 59  ///
 60  /// ```rust
 61  /// use dioxus::prelude::*;
 62  /// use dioxus_primitives::context_menu::{
 63  ///     ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
 64  /// };
 65  /// #[component]
 66  /// fn Demo() -> Element {
 67  ///     rsx! {
 68  ///         ContextMenu {
 69  ///             ContextMenuTrigger {
 70  ///                 "right click here"
 71  ///             }
 72  ///             ContextMenuContent {
 73  ///                 ContextMenuItem {
 74  ///                     value: "edit".to_string(),
 75  ///                     index: 0usize,
 76  ///                     on_select: move |value| {
 77  ///                         tracing::info!("Selected item: {}", value);
 78  ///                     },
 79  ///                     "Edit"
 80  ///                 }
 81  ///                 ContextMenuItem {
 82  ///                     value: "undo".to_string(),
 83  ///                     index: 1usize,
 84  ///                     disabled: true,
 85  ///                     on_select: move |value| {
 86  ///                         tracing::info!("Selected item: {}", value);
 87  ///                     },
 88  ///                     "Undo"
 89  ///                 }
 90  ///             }
 91  ///         }
 92  ///     }
 93  /// }
 94  /// ```
 95  ///
 96  /// ## Styling
 97  ///
 98  /// The [`ContextMenu`] component defines the following data attributes you can use to control styling:
 99  /// - `data-state`: Indicates if the state of the context menu. Values are `open` or `closed`.
100  /// - `data-disabled`: Indicates if the context menu is disabled. values are `true` or `false`.
101  #[component]
102  pub fn ContextMenu(props: ContextMenuProps) -> Element {
103      let (open, set_open) = use_controlled(props.open, props.default_open, props.on_open_change);
104      let position = use_signal(|| (0, 0));
105  
106      let focus = use_focus_provider(props.roving_loop);
107      let mut ctx = use_context_provider(|| ContextMenuCtx {
108          open,
109          set_open,
110          disabled: props.disabled,
111          position,
112          focus,
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      // If the context menu is open, prevent pointer and scroll events outside of it
123      let pointer_events_disabled = |disabled| {
124          if disabled {
125              dioxus::document::eval(
126                  "document.body.style.pointerEvents = 'none'; document.documentElement.style.overflow = 'hidden';",
127              );
128          } else {
129              dioxus::document::eval(
130                  "document.body.style.pointerEvents = 'auto'; document.documentElement.style.overflow = 'auto';",
131              );
132          }
133      };
134      use_effect(move || {
135          pointer_events_disabled(ctx.open.cloned());
136      });
137      use_effect_cleanup(move || {
138          // If the context menu was open, reset pointer events
139          if ctx.open.cloned() {
140              pointer_events_disabled(false);
141          }
142      });
143  
144      // Handle escape key to close the menu
145      let handle_keydown = move |event: Event<KeyboardData>| {
146          if open() && event.key() == Key::Escape {
147              event.prevent_default();
148              set_open.call(false);
149              ctx.focus.blur();
150          }
151      };
152  
153      rsx! {
154          div {
155              tabindex: 0, // Make the menu container focusable
156              onkeydown: handle_keydown,
157              "data-state": if open() { "open" } else { "closed" },
158              "data-disabled": (props.disabled)(),
159              ..props.attributes,
160              {props.children}
161          }
162      }
163  }
164  
165  /// The props for the [`ContextMenuTrigger`] component.
166  #[derive(Props, Clone, PartialEq)]
167  pub struct ContextMenuTriggerProps {
168      /// Additional attributes for the context menu trigger element.
169      #[props(extends = GlobalAttributes)]
170      pub attributes: Vec<Attribute>,
171  
172      /// The children of the context menu trigger.
173      pub children: Element,
174  }
175  
176  /// # ContextMenuTrigger
177  ///
178  /// The [`ContextMenuTrigger`] component is used to define the element that will trigger the context menu when right-clicked.
179  ///
180  /// This must be used inside a [`ContextMenu`] component.
181  ///
182  /// ## Example
183  ///
184  /// ```rust
185  /// use dioxus::prelude::*;
186  /// use dioxus_primitives::context_menu::{
187  ///     ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
188  /// };
189  /// #[component]
190  /// fn Demo() -> Element {
191  ///     rsx! {
192  ///         ContextMenu {
193  ///             ContextMenuTrigger {
194  ///                 "right click here"
195  ///             }
196  ///             ContextMenuContent {
197  ///                 ContextMenuItem {
198  ///                     value: "edit".to_string(),
199  ///                     index: 0usize,
200  ///                     on_select: move |value| {
201  ///                         tracing::info!("Selected item: {}", value);
202  ///                     },
203  ///                     "Edit"
204  ///                 }
205  ///                 ContextMenuItem {
206  ///                     value: "undo".to_string(),
207  ///                     index: 1usize,
208  ///                     disabled: true,
209  ///                     on_select: move |value| {
210  ///                         tracing::info!("Selected item: {}", value);
211  ///                     },
212  ///                     "Undo"
213  ///                 }
214  ///             }
215  ///         }
216  ///     }
217  /// }
218  /// ```
219  #[component]
220  pub fn ContextMenuTrigger(props: ContextMenuTriggerProps) -> Element {
221      let mut ctx: ContextMenuCtx = use_context();
222  
223      let handle_context_menu = move |event: Event<MouseData>| {
224          if !(ctx.disabled)() {
225              ctx.position.set((
226                  event.data().client_coordinates().x as i32,
227                  event.data().client_coordinates().y as i32,
228              ));
229              ctx.set_open.call(true);
230              event.prevent_default();
231          }
232      };
233  
234      rsx! {
235          div {
236              oncontextmenu: handle_context_menu,
237              role: "button",
238              aria_haspopup: "menu",
239              aria_expanded: (ctx.open)(),
240              ..props.attributes,
241              {props.children}
242          }
243      }
244  }
245  
246  /// The props for the [`ContextMenuContent`] component.
247  #[derive(Props, Clone, PartialEq)]
248  pub struct ContextMenuContentProps {
249      /// The ID of the context menu content element.
250      pub id: ReadSignal<Option<String>>,
251  
252      /// Additional attributes for the context menu content element.
253      #[props(extends = GlobalAttributes)]
254      pub attributes: Vec<Attribute>,
255  
256      /// The children of the context menu content.
257      pub children: Element,
258  }
259  
260  /// # ContextMenuContent
261  ///
262  /// The [`ContextMenuContent`] component is used to define the content of the context menu. It is only rendered
263  /// when the context menu is open.
264  ///
265  /// This must be used inside a [`ContextMenu`] component.
266  ///
267  /// ## Example
268  ///
269  /// ```rust
270  /// use dioxus::prelude::*;
271  /// use dioxus_primitives::context_menu::{
272  ///     ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
273  /// };
274  /// #[component]
275  /// fn Demo() -> Element {
276  ///     rsx! {
277  ///         ContextMenu {
278  ///             ContextMenuTrigger {
279  ///                 "right click here"
280  ///             }
281  ///             ContextMenuContent {
282  ///                 ContextMenuItem {
283  ///                     value: "edit".to_string(),
284  ///                     index: 0usize,
285  ///                     on_select: move |value| {
286  ///                         tracing::info!("Selected item: {}", value);
287  ///                     },
288  ///                     "Edit"
289  ///                 }
290  ///                 ContextMenuItem {
291  ///                     value: "undo".to_string(),
292  ///                     index: 1usize,
293  ///                     disabled: true,
294  ///                     on_select: move |value| {
295  ///                         tracing::info!("Selected item: {}", value);
296  ///                     },
297  ///                     "Undo"
298  ///                 }
299  ///             }
300  ///         }
301  ///     }
302  /// }
303  /// ```
304  ///
305  /// ## Styling
306  ///
307  /// The [`ContextMenuContent`] component defines the following data attributes you can use to control styling:
308  /// - `data-state`: Indicates if the state of the context menu. Values are `open` or `closed`.
309  #[component]
310  pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element {
311      let mut ctx: ContextMenuCtx = use_context();
312      let position = ctx.position;
313      let (x, y) = position();
314  
315      let open = ctx.open;
316  
317      let onkeydown = move |event: Event<KeyboardData>| {
318          match event.key() {
319              Key::Escape => ctx.focus.blur(),
320              Key::ArrowDown => {
321                  ctx.focus.focus_next();
322              }
323              Key::ArrowUp => {
324                  if open() {
325                      ctx.focus.focus_prev();
326                  }
327              }
328              Key::Home => ctx.focus.focus_first(),
329              Key::End => ctx.focus.focus_last(),
330              _ => return,
331          }
332          event.prevent_default();
333      };
334  
335      let mut menu_ref: Signal<Option<std::rc::Rc<MountedData>>> = use_signal(|| None);
336      let focused = move || open() && !ctx.focus.any_focused();
337      // If the menu is open, but no item is focused, focus the div itself to capture events
338      use_effect(move || {
339          let Some(menu) = menu_ref() else {
340              return;
341          };
342          if focused() {
343              spawn(async move {
344                  // Focus the menu itself to capture keyboard events
345                  _ = menu.set_focus(true).await;
346              });
347          }
348      });
349  
350      let unique_id = use_unique_id();
351      let id = use_id_or(unique_id, props.id);
352  
353      let render = use_animated_open(id, open);
354  
355      rsx! {
356          if render() {
357              div {
358                  id,
359                  role: "menu",
360                  aria_orientation: "vertical",
361                  position: "fixed",
362                  left: "{x}px",
363                  top: "{y}px",
364                  tabindex: if focused() { "0" } else { "-1" },
365                  pointer_events: open().then_some("auto"),
366                  "data-state": if open() { "open" } else { "closed" },
367                  onkeydown,
368                  onblur: move |_| {
369                      if focused() {
370                          ctx.focus.blur();
371                      }
372                  },
373                  onmounted: move |evt| menu_ref.set(Some(evt.data())),
374                  ..props.attributes,
375  
376                  {props.children}
377              }
378          }
379      }
380  }
381  
382  /// The props for the [`ContextMenuItem`] component.
383  #[derive(Props, Clone, PartialEq)]
384  pub struct ContextMenuItemProps {
385      /// Whether the item is disabled
386      #[props(default = ReadSignal::new(Signal::new(false)))]
387      pub disabled: ReadSignal<bool>,
388  
389      /// The value of the menu item
390      pub value: ReadSignal<String>,
391  
392      /// The index of the item in the menu
393      pub index: ReadSignal<usize>,
394  
395      /// Callback when the item is selected
396      #[props(default)]
397      pub on_select: Callback<String>,
398  
399      /// Additional attributes for the context menu item element
400      #[props(extends = GlobalAttributes)]
401      pub attributes: Vec<Attribute>,
402  
403      /// The children of the context menu item
404      pub children: Element,
405  }
406  
407  /// # ContextMenuItem
408  ///
409  /// The [`ContextMenuItem`] component defines an individual item in the context menu. You must define an index that
410  /// controls the order items are focused when navigating the menu with the keyboard.
411  ///
412  /// When an item is selected with either the pointer or the keyboard, the menu is closed and the `on_select` callback is called with the item's value.
413  ///
414  /// This must be used inside a [`ContextMenuContent`] component.
415  ///
416  /// ## Example
417  ///
418  /// ```rust
419  /// use dioxus::prelude::*;
420  /// use dioxus_primitives::context_menu::{
421  ///     ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
422  /// };
423  /// #[component]
424  /// fn Demo() -> Element {
425  ///     rsx! {
426  ///         ContextMenu {
427  ///             ContextMenuTrigger {
428  ///                 "right click here"
429  ///             }
430  ///             ContextMenuContent {
431  ///                 ContextMenuItem {
432  ///                     value: "edit".to_string(),
433  ///                     index: 0usize,
434  ///                     on_select: move |value| {
435  ///                         tracing::info!("Selected item: {}", value);
436  ///                     },
437  ///                     "Edit"
438  ///                 }
439  ///                 ContextMenuItem {
440  ///                     value: "undo".to_string(),
441  ///                     index: 1usize,
442  ///                     disabled: true,
443  ///                     on_select: move |value| {
444  ///                         tracing::info!("Selected item: {}", value);
445  ///                     },
446  ///                     "Undo"
447  ///                 }
448  ///             }
449  ///         }
450  ///     }
451  /// }
452  /// ```
453  ///
454  /// ## Styling
455  ///
456  /// The [`ContextMenuItem`] component defines the following data attributes you can use to control styling:
457  /// - `data-disabled`: Indicates if the item is disabled. Possible values are `true` or `false`.
458  #[component]
459  pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element {
460      let mut ctx: ContextMenuCtx = use_context();
461  
462      let disabled = use_memo(move || (props.disabled)() || (ctx.disabled)());
463      let focused = move || ctx.focus.is_focused(props.index.cloned());
464  
465      // Handle settings focus
466      let onmounted = use_focus_controlled_item(props.index);
467  
468      // Determine if this item is currently focused
469      let tab_index = use_memo(move || if focused() { "0" } else { "-1" });
470  
471      let handle_click = {
472          let value = (props.value)().clone();
473          move |event: Event<PointerData>| {
474              if !disabled() {
475                  props.on_select.call(value.clone());
476                  ctx.focus.blur();
477                  event.prevent_default();
478                  event.stop_propagation();
479              }
480          }
481      };
482  
483      let handle_keydown = {
484          let value = (props.value)().clone();
485          move |event: Event<KeyboardData>| {
486              // Check for Enter or Space key
487              if event.key() == Key::Enter || event.key() == Key::Character(" ".to_string()) {
488                  if !disabled() {
489                      props.on_select.call(value.clone());
490                      ctx.focus.blur();
491                  }
492                  event.prevent_default();
493                  event.stop_propagation();
494              }
495          }
496      };
497  
498      rsx! {
499          div {
500              role: "menuitem",
501              tabindex: tab_index,
502              onpointerdown: handle_click,
503              onkeydown: handle_keydown,
504              onblur: move |_| {
505                  if focused() {
506                      ctx.focus.blur();
507                  }
508              },
509              onmounted,
510              aria_disabled: disabled(),
511              "data-disabled": disabled(),
512              ..props.attributes,
513  
514              {props.children}
515          }
516      }
517  }