dioxus_ui_system/molecules/
context_menu.rs1use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Props, Clone, PartialEq)]
12pub struct ContextMenuProps {
13 pub children: Element,
15}
16
17#[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#[derive(Clone, Copy)]
42struct ContextMenuContext {
43 is_open: Signal<bool>,
44 menu_position: Signal<(i32, i32)>,
45 focused_index: Signal<usize>,
46}
47
48#[derive(Props, Clone, PartialEq)]
50pub struct ContextMenuTriggerProps {
51 pub children: Element,
53}
54
55#[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 let coords = event.data().page_coordinates();
67 let click_x = coords.x as i32;
68 let click_y = coords.y as i32;
69
70 let padding = 8;
72
73 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#[derive(Props, Clone, PartialEq)]
92pub struct ContextMenuContentProps {
93 pub children: Element,
95}
96
97#[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 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 let handle_overlay_click = move |_| {
141 ctx.is_open.set(false);
142 };
143
144 rsx! {
145 if *ctx.is_open.read() {
146 div {
148 style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998;",
149 onclick: handle_overlay_click,
150 }
151
152 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#[derive(Props, Clone, PartialEq)]
167pub struct ContextMenuItemProps {
168 pub children: Element,
170 #[props(default)]
172 pub on_click: Option<EventHandler<()>>,
173 #[props(default = false)]
175 pub disabled: bool,
176 #[props(default)]
178 pub shortcut: Option<String>,
179 #[props(default)]
181 pub icon: Option<Element>,
182}
183
184#[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 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 if let Some(shortcut) = &props.shortcut {
255 span {
256 style: "{shortcut_style} margin-left: auto;",
257 "{shortcut}"
258 }
259 }
260 }
261 }
262}
263
264#[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#[derive(Props, Clone, PartialEq)]
290pub struct ContextMenuLabelProps {
291 pub children: Element,
293}
294
295#[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#[derive(Props, Clone, PartialEq)]
322pub struct ContextMenuCheckboxItemProps {
323 pub children: Element,
325 #[props(default = false)]
327 pub checked: bool,
328 #[props(default)]
330 pub on_checked_change: Option<EventHandler<bool>>,
331 #[props(default = false)]
333 pub disabled: bool,
334 #[props(default)]
336 pub shortcut: Option<String>,
337}
338
339#[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 div {
420 style: "display: flex; align-items: center;",
421
422 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 if let Some(shortcut) = &props.shortcut {
438 span {
439 style: "{shortcut_style} margin-left: auto;",
440 "{shortcut}"
441 }
442 }
443 }
444 }
445}
446
447#[derive(Props, Clone, PartialEq)]
449pub struct ContextMenuSubProps {
450 pub trigger: Element,
452 pub children: Element,
454}
455
456#[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 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 if is_open() {
496 div {
497 onmouseleave: move |_| is_open.set(false),
498
499 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 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#[derive(Props, Clone, PartialEq)]
520pub struct ContextMenuSubTriggerProps {
521 pub children: Element,
523 #[props(default = false)]
525 pub disabled: bool,
526 #[props(default)]
528 pub shortcut: Option<String>,
529}
530
531#[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 div {
586 style: "display: flex; align-items: center; gap: 8px;",
587 {props.children}
588 }
589
590 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}