perspective_viewer/components/context_menu.rs
1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use web_sys::MouseEvent;
14use yew::prelude::*;
15
16/// A single command row in a [`ContextMenu`].
17#[derive(Clone, PartialEq)]
18pub struct ContextMenuItem {
19 pub label: String,
20 pub on_select: Callback<()>,
21 pub disabled: bool,
22}
23
24/// A row in a [`ContextMenu`]: a plain command [`ContextMenuItem`], a
25/// non-interactive group label, or a row with a hover-opened sub-menu flyout.
26#[derive(Clone, PartialEq)]
27pub enum ContextMenuEntry {
28 Item(ContextMenuItem),
29 Header(String),
30 Submenu {
31 label: String,
32
33 /// Click-through command for the parent row itself (`None` makes the
34 /// row hover-only).
35 on_select: Option<Callback<()>>,
36 entries: Vec<ContextMenuEntry>,
37 },
38}
39
40#[derive(Properties, PartialEq)]
41pub struct ContextMenuProps {
42 pub entries: Vec<ContextMenuEntry>,
43
44 /// Fired after an item is selected. Outside-click dismissal is the
45 /// enclosing `PortalModal`'s job (blur), not this component's.
46 #[prop_or_default]
47 pub on_close: Callback<()>,
48}
49
50/// The command-item list of a panel context menu. Purely presentational — the
51/// command set is supplied by the parent as plain [`Callback`]s, and
52/// positioning/dismissal/theming belong to the enclosing `PortalModal` (the
53/// same body-mounted, `theme`-attributed delivery as the Export/Copy menus).
54/// Sub-menus are CSS-only `:hover` flyouts nested inside their parent row, so
55/// they live inside the same `PortalModal` host (focus never leaves it) and
56/// selection routes through the same `on_select` + `on_close` path.
57pub struct ContextMenu;
58
59impl Component for ContextMenu {
60 type Message = ();
61 type Properties = ContextMenuProps;
62
63 fn create(_ctx: &Context<Self>) -> Self {
64 Self
65 }
66
67 fn view(&self, ctx: &Context<Self>) -> Html {
68 let props = ctx.props();
69 let entries = props
70 .entries
71 .iter()
72 .map(|entry| render_entry(entry, &props.on_close));
73
74 html! { <div class="context-menu">{ for entries }</div> }
75 }
76}
77
78fn render_entry(entry: &ContextMenuEntry, on_close: &Callback<()>) -> Html {
79 match entry {
80 ContextMenuEntry::Item(item) => {
81 let disabled = item.disabled;
82 let onclick = {
83 let on_select = item.on_select.clone();
84 let on_close = on_close.clone();
85 Callback::from(move |e: MouseEvent| {
86 e.stop_propagation();
87 if !disabled {
88 on_select.emit(());
89 on_close.emit(());
90 }
91 })
92 };
93
94 let mut class = classes!("context-menu-item");
95 if disabled {
96 class.push("disabled");
97 }
98
99 html! { <span {class} {onclick}>{ item.label.clone() }</span> }
100 },
101 ContextMenuEntry::Header(label) => {
102 html! { <span class="context-menu-header">{ label.clone() }</span> }
103 },
104 ContextMenuEntry::Submenu {
105 label,
106 on_select,
107 entries,
108 } => {
109 let onclick = {
110 let on_select = on_select.clone();
111 let on_close = on_close.clone();
112 Callback::from(move |e: MouseEvent| {
113 e.stop_propagation();
114 if let Some(on_select) = &on_select {
115 on_select.emit(());
116 on_close.emit(());
117 }
118 })
119 };
120
121 let children = entries.iter().map(|entry| render_entry(entry, on_close));
122 html! {
123 <span class="context-menu-item has-submenu" {onclick}>
124 { label.clone() }
125 <div class="context-menu context-menu-submenu">{ for children }</div>
126 </span>
127 }
128 },
129 }
130}