Skip to main content

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}