Skip to main content

dioxus_ui_system/molecules/
context_menu.rs

1//! Context Menu molecule component
2//!
3//! A right-click context menu that displays actions when the user right-clicks on an element.
4//! Uses fixed positioning with portal-like behavior to escape parent overflow clipping.
5
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Context Menu properties
11#[derive(Props, Clone, PartialEq)]
12pub struct ContextMenuProps {
13    /// Child elements (should include ContextMenuTrigger and ContextMenuContent)
14    pub children: Element,
15}
16
17/// Context menu container component
18///
19/// Provides the context for child trigger and content components.
20#[component]
21pub fn ContextMenu(props: ContextMenuProps) -> Element {
22    let is_open = use_signal(|| false);
23    let menu_position = use_signal(|| (0i32, 0i32));
24    let focused_index = use_signal(|| 0usize);
25
26    use_context_provider(|| ContextMenuContext {
27        is_open,
28        menu_position,
29        focused_index,
30    });
31
32    rsx! {
33        div {
34            style: "display: inline-block;",
35            {props.children}
36        }
37    }
38}
39
40/// Context for sharing state between context menu components
41#[derive(Clone, Copy)]
42struct ContextMenuContext {
43    is_open: Signal<bool>,
44    menu_position: Signal<(i32, i32)>,
45    focused_index: Signal<usize>,
46}
47
48/// Context Menu Trigger properties
49#[derive(Props, Clone, PartialEq)]
50pub struct ContextMenuTriggerProps {
51    /// Child element that triggers the context menu on right-click
52    pub children: Element,
53}
54
55/// Context menu trigger component
56///
57/// Wraps the element that will trigger the context menu on right-click.
58#[component]
59pub fn ContextMenuTrigger(props: ContextMenuTriggerProps) -> Element {
60    let mut ctx: ContextMenuContext = use_context();
61
62    let handle_context_menu = move |event: Event<MouseData>| {
63        event.prevent_default();
64
65        // Get click coordinates
66        let coords = event.data().page_coordinates();
67        let click_x = coords.x as i32;
68        let click_y = coords.y as i32;
69
70        // Basic padding to ensure menu is not at the very edge
71        let padding = 8;
72
73        // Position with basic bounds checking
74        let menu_x = click_x.max(padding);
75        let menu_y = click_y.max(padding);
76
77        ctx.menu_position.set((menu_x, menu_y));
78        ctx.is_open.set(true);
79        ctx.focused_index.set(0);
80    };
81
82    rsx! {
83        div {
84            oncontextmenu: handle_context_menu,
85            {props.children}
86        }
87    }
88}
89
90/// Context Menu Content properties
91#[derive(Props, Clone, PartialEq)]
92pub struct ContextMenuContentProps {
93    /// Menu items and content
94    pub children: Element,
95}
96
97/// Context menu content component
98///
99/// The actual menu that appears on right-click, positioned at cursor location.
100#[component]
101pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element {
102    let _theme = use_theme();
103    let mut ctx: ContextMenuContext = use_context();
104
105    let menu_base_style = use_style(|t| {
106        Style::new()
107            .rounded(&t.radius, "md")
108            .border(1, &t.colors.border)
109            .bg(&t.colors.popover)
110            .shadow(&t.shadows.lg)
111            .flex()
112            .flex_col()
113            .py(&t.spacing, "xs")
114            .min_w_px(160)
115            .z_index(9999)
116            .outline("none")
117            .build()
118    });
119
120    let menu_x = ctx.menu_position.read().0;
121    let menu_y = ctx.menu_position.read().1;
122    let position_style = format!("position: fixed; left: {}px; top: {}px;", menu_x, menu_y);
123
124    // Handle keyboard navigation
125    let handle_keydown = move |event: Event<KeyboardData>| {
126        use dioxus::html::input_data::keyboard_types::Key;
127        let key = event.key();
128        if key == Key::Escape {
129            ctx.is_open.set(false);
130        } else if key == Key::ArrowDown {
131            event.prevent_default();
132            ctx.focused_index.with_mut(|i| *i = i.saturating_add(1));
133        } else if key == Key::ArrowUp {
134            event.prevent_default();
135            ctx.focused_index.with_mut(|i| *i = i.saturating_sub(1));
136        }
137    };
138
139    // Close when clicking outside
140    let handle_overlay_click = move |_| {
141        ctx.is_open.set(false);
142    };
143
144    rsx! {
145        if *ctx.is_open.read() {
146            // Overlay for outside click
147            div {
148                style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998;",
149                onclick: handle_overlay_click,
150            }
151
152            // Menu content
153            div {
154                style: "{menu_base_style} {position_style}",
155                tabindex: "0",
156                role: "menu",
157                onkeydown: handle_keydown,
158                onclick: move |e| e.stop_propagation(),
159                {props.children}
160            }
161        }
162    }
163}
164
165/// Context Menu Item properties
166#[derive(Props, Clone, PartialEq)]
167pub struct ContextMenuItemProps {
168    /// Item content (typically text)
169    pub children: Element,
170    /// Callback when item is clicked
171    #[props(default)]
172    pub on_click: Option<EventHandler<()>>,
173    /// Whether the item is disabled
174    #[props(default = false)]
175    pub disabled: bool,
176    /// Optional keyboard shortcut display
177    #[props(default)]
178    pub shortcut: Option<String>,
179    /// Optional icon element
180    #[props(default)]
181    pub icon: Option<Element>,
182}
183
184/// Context menu item component
185///
186/// A clickable item within the context menu.
187#[component]
188pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element {
189    let _theme = use_theme();
190    let mut is_hovered = use_signal(|| false);
191    let mut ctx: ContextMenuContext = use_context();
192
193    let item_style = use_style(move |t| {
194        let base = Style::new()
195            .w_full()
196            .flex()
197            .items_center()
198            .justify_between()
199            .gap(&t.spacing, "sm")
200            .px(&t.spacing, "sm")
201            .py(&t.spacing, "sm")
202            .rounded(&t.radius, "sm")
203            .text(&t.typography, "sm")
204            .cursor(if props.disabled {
205                "not-allowed"
206            } else {
207                "pointer"
208            })
209            .opacity(if props.disabled { 0.5 } else { 1.0 })
210            .outline("none");
211
212        if is_hovered() && !props.disabled {
213            base.bg(&t.colors.accent).build()
214        } else {
215            base.build()
216        }
217    });
218
219    let handle_click = move |_| {
220        if !props.disabled {
221            if let Some(ref handler) = props.on_click {
222                handler.call(());
223            }
224            ctx.is_open.set(false);
225        }
226    };
227
228    let shortcut_style = use_style(|t| {
229        Style::new()
230            .text(&t.typography, "xs")
231            .text_color(&t.colors.muted_foreground)
232            .build()
233    });
234
235    rsx! {
236        div {
237            style: "{item_style} user-select: none;",
238            role: "menuitem",
239            aria_disabled: props.disabled,
240            onmouseenter: move |_| is_hovered.set(true),
241            onmouseleave: move |_| is_hovered.set(false),
242            onclick: handle_click,
243
244            // Left section: icon and content
245            div {
246                style: "display: flex; align-items: center; gap: 8px;",
247                if let Some(icon) = props.icon.clone() {
248                    {icon}
249                }
250                {props.children}
251            }
252
253            // Right section: shortcut
254            if let Some(shortcut) = &props.shortcut {
255                span {
256                    style: "{shortcut_style} margin-left: auto;",
257                    "{shortcut}"
258                }
259            }
260        }
261    }
262}
263
264/// Context menu separator component
265///
266/// A horizontal divider between menu items.
267#[component]
268pub fn ContextMenuSeparator() -> Element {
269    let _theme = use_theme();
270
271    let separator_style = use_style(|t| {
272        Style::new()
273            .h_px(1)
274            .mx(&t.spacing, "sm")
275            .my(&t.spacing, "xs")
276            .bg(&t.colors.border)
277            .build()
278    });
279
280    rsx! {
281        div {
282            style: "{separator_style}",
283            role: "separator",
284        }
285    }
286}
287
288/// Context Menu Label properties
289#[derive(Props, Clone, PartialEq)]
290pub struct ContextMenuLabelProps {
291    /// Label text content
292    pub children: Element,
293}
294
295/// Context menu label component
296///
297/// A non-clickable label for grouping menu items.
298#[component]
299pub fn ContextMenuLabel(props: ContextMenuLabelProps) -> Element {
300    let _theme = use_theme();
301
302    let label_style = use_style(|t| {
303        Style::new()
304            .px(&t.spacing, "sm")
305            .py(&t.spacing, "xs")
306            .text(&t.typography, "xs")
307            .font_weight(500)
308            .text_color(&t.colors.muted_foreground)
309            .build()
310    });
311
312    rsx! {
313        div {
314            style: "{label_style} user-select: none;",
315            {props.children}
316        }
317    }
318}
319
320/// Context Menu Checkbox Item properties
321#[derive(Props, Clone, PartialEq)]
322pub struct ContextMenuCheckboxItemProps {
323    /// Item label
324    pub children: Element,
325    /// Whether the checkbox is checked
326    #[props(default = false)]
327    pub checked: bool,
328    /// Callback when checked state changes
329    #[props(default)]
330    pub on_checked_change: Option<EventHandler<bool>>,
331    /// Whether the item is disabled
332    #[props(default = false)]
333    pub disabled: bool,
334    /// Optional keyboard shortcut display
335    #[props(default)]
336    pub shortcut: Option<String>,
337}
338
339/// Context menu checkbox item component
340///
341/// An item with a checkbox that can be toggled.
342#[component]
343pub fn ContextMenuCheckboxItem(props: ContextMenuCheckboxItemProps) -> Element {
344    let _theme = use_theme();
345    let mut is_hovered = use_signal(|| false);
346    let _ctx: ContextMenuContext = use_context();
347
348    let item_style = use_style(move |t| {
349        let base = Style::new()
350            .w_full()
351            .flex()
352            .items_center()
353            .justify_between()
354            .gap(&t.spacing, "sm")
355            .px(&t.spacing, "sm")
356            .py(&t.spacing, "sm")
357            .rounded(&t.radius, "sm")
358            .text(&t.typography, "sm")
359            .cursor(if props.disabled {
360                "not-allowed"
361            } else {
362                "pointer"
363            })
364            .opacity(if props.disabled { 0.5 } else { 1.0 })
365            .outline("none");
366
367        if is_hovered() && !props.disabled {
368            base.bg(&t.colors.accent).build()
369        } else {
370            base.build()
371        }
372    });
373
374    let handle_click = move |_| {
375        if !props.disabled {
376            if let Some(ref handler) = props.on_checked_change {
377                handler.call(!props.checked);
378            }
379        }
380    };
381
382    let checkbox_style = use_style(|t| {
383        Style::new()
384            .w_px(16)
385            .h_px(16)
386            .flex()
387            .items_center()
388            .justify_center()
389            .rounded(&t.radius, "sm")
390            .border(1, &t.colors.border)
391            .build()
392    });
393
394    let check_icon_style = use_style(|t| {
395        Style::new()
396            .text(&t.typography, "xs")
397            .text_color(&t.colors.primary)
398            .build()
399    });
400
401    let shortcut_style = use_style(|t| {
402        Style::new()
403            .text(&t.typography, "xs")
404            .text_color(&t.colors.muted_foreground)
405            .build()
406    });
407
408    rsx! {
409        div {
410            style: "{item_style} user-select: none;",
411            role: "menuitemcheckbox",
412            aria_checked: props.checked,
413            aria_disabled: props.disabled,
414            onmouseenter: move |_| is_hovered.set(true),
415            onmouseleave: move |_| is_hovered.set(false),
416            onclick: handle_click,
417
418            // Left section: checkbox and content
419            div {
420                style: "display: flex; align-items: center;",
421
422                // Checkbox indicator
423                div {
424                    style: "{checkbox_style} margin-right: 8px;",
425                    if props.checked {
426                        span {
427                            style: "{check_icon_style}",
428                            "✓"
429                        }
430                    }
431                }
432
433                {props.children}
434            }
435
436            // Right section: shortcut
437            if let Some(shortcut) = &props.shortcut {
438                span {
439                    style: "{shortcut_style} margin-left: auto;",
440                    "{shortcut}"
441                }
442            }
443        }
444    }
445}
446
447/// Context Menu Submenu properties
448#[derive(Props, Clone, PartialEq)]
449pub struct ContextMenuSubProps {
450    /// Trigger element that opens the submenu
451    pub trigger: Element,
452    /// Submenu content
453    pub children: Element,
454}
455
456/// Context menu submenu component
457///
458/// A nested menu that appears when hovering over a parent item.
459#[component]
460pub fn ContextMenuSub(props: ContextMenuSubProps) -> Element {
461    let _theme = use_theme();
462    let mut is_open = use_signal(|| false);
463    let mut menu_position = use_signal(|| (0i32, 0i32));
464
465    let submenu_style = use_style(|t| {
466        Style::new()
467            .rounded(&t.radius, "md")
468            .border(1, &t.colors.border)
469            .bg(&t.colors.popover)
470            .shadow(&t.shadows.lg)
471            .flex()
472            .flex_col()
473            .py(&t.spacing, "xs")
474            .min_w_px(160)
475            .z_index(10000)
476            .outline("none")
477            .build()
478    });
479
480    rsx! {
481        div {
482            style: "position: relative;",
483
484            // Trigger with hover handling
485            div {
486                onmouseenter: move |e: Event<MouseData>| {
487                    let coords = e.data().page_coordinates();
488                    menu_position.set((coords.x as i32 + 160, coords.y as i32));
489                    is_open.set(true);
490                },
491                {props.trigger}
492            }
493
494            // Submenu content
495            if is_open() {
496                div {
497                    onmouseleave: move |_| is_open.set(false),
498
499                    // Submenu overlay
500                    div {
501                        style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9997;",
502                        onmouseenter: move |_| is_open.set(false),
503                    }
504
505                    // Submenu
506                    div {
507                        style: "{submenu_style} position: fixed; left: {menu_position.read().0}px; top: {menu_position.read().1}px;",
508                        role: "menu",
509                        onclick: move |e| e.stop_propagation(),
510                        {props.children}
511                    }
512                }
513            }
514        }
515    }
516}
517
518/// Context Menu Sub Trigger properties
519#[derive(Props, Clone, PartialEq)]
520pub struct ContextMenuSubTriggerProps {
521    /// Trigger content
522    pub children: Element,
523    /// Whether the item is disabled
524    #[props(default = false)]
525    pub disabled: bool,
526    /// Optional keyboard shortcut display
527    #[props(default)]
528    pub shortcut: Option<String>,
529}
530
531/// Context menu sub trigger component
532///
533/// An item that opens a submenu on hover.
534#[component]
535pub fn ContextMenuSubTrigger(props: ContextMenuSubTriggerProps) -> Element {
536    let _theme = use_theme();
537    let mut is_hovered = use_signal(|| false);
538
539    let item_style = use_style(move |t| {
540        let base = Style::new()
541            .w_full()
542            .flex()
543            .items_center()
544            .justify_between()
545            .gap(&t.spacing, "sm")
546            .px(&t.spacing, "sm")
547            .py(&t.spacing, "sm")
548            .rounded(&t.radius, "sm")
549            .text(&t.typography, "sm")
550            .cursor(if props.disabled {
551                "not-allowed"
552            } else {
553                "pointer"
554            })
555            .opacity(if props.disabled { 0.5 } else { 1.0 })
556            .outline("none");
557
558        if is_hovered() && !props.disabled {
559            base.bg(&t.colors.accent).build()
560        } else {
561            base.build()
562        }
563    });
564
565    let shortcut_style = use_style(|t| {
566        Style::new()
567            .text(&t.typography, "xs")
568            .text_color(&t.colors.muted_foreground)
569            .flex()
570            .items_center()
571            .gap(&t.spacing, "xs")
572            .build()
573    });
574
575    rsx! {
576        div {
577            style: "{item_style} user-select: none;",
578            role: "menuitem",
579            aria_disabled: props.disabled,
580            aria_haspopup: "menu",
581            onmouseenter: move |_| is_hovered.set(true),
582            onmouseleave: move |_| is_hovered.set(false),
583
584            // Content
585            div {
586                style: "display: flex; align-items: center; gap: 8px;",
587                {props.children}
588            }
589
590            // Shortcut and chevron
591            div {
592                style: "{shortcut_style} margin-left: auto;",
593                if let Some(shortcut) = &props.shortcut {
594                    span { "{shortcut}" }
595                }
596                span { "›" }
597            }
598        }
599    }
600}