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 }