Skip to main content

eui_core/layout_tree/
overlays.rs

1//! Overlay natives (Dialog, Drawer, Popover, Tooltip, Menu), menus with
2//! checkbox / label / separator entries and ONE level of submenus, and the
3//! Toast band. The trigger stays inline; the content is a LAYER with its own
4//! root (`<owner>.content`), placed by the surface against the viewport, an
5//! anchor or a point. Round 3: `Menu` replaces DropdownMenu + ContextMenu
6//! (`openOn: press` = its ONE child is the trigger, `contextmenu` = its ONE
7//! child is the target region, opened at the pointer).
8
9use serde_json::{json, Value};
10
11use super::{bool_prop, js, str_prop, Builder, LNode, LayerPlacement, NodeKind, ToastSpec};
12use crate::overlay::{OverlayAlign, OverlaySide};
13use crate::theme::RecipeQuery;
14use crate::types::UiNode;
15
16/// True for a `Menu` opened by a right click / long press (`openOn:
17/// contextmenu`): its ONE child is the target region.
18pub fn is_context_menu(component: &str, props: &crate::types::Props) -> bool {
19    component == "Menu" && str_prop(props, "openOn") == Some("contextmenu")
20}
21
22/// Toasts visible at once (the newest three).
23pub const MAX_TOASTS: usize = 3;
24
25impl Builder<'_, '_> {
26    /// A layer root with an id suffix (`<owner>.<part><suffix>`).
27    fn layer_root_suffixed(&mut self, owner: &LNode, part: &str, suffix: &str, style: Value) -> (u32, u32) {
28        let (layer, root) = self.layer_root(owner, part, style);
29        if !suffix.is_empty() {
30            let id = format!("{}.{part}{suffix}", owner.id);
31            self.nodes[root as usize].id = id;
32        }
33        (layer, root)
34    }
35
36    pub(crate) fn overlay(&mut self, index: u32, node: &UiNode, scope: &str) {
37        let owner = self.nodes[index as usize].clone();
38        let layer_parent = owner.layer;
39        let kind = owner.component.clone();
40        let context_menu = is_context_menu(&owner.component, &owner.props);
41        let press_menu = kind == "Menu" && !context_menu;
42        // Round 2 §7: the overlay is LAYOUT-TRANSPARENT: the wrapper is the
43        // flex item and takes the parent's alignment. A control trigger
44        // (Button, Toggle) keeps its own size at the wrapper's start (VAPP-128:
45        // the wrapper is no longer pinned to flex-start, so a Menu button in
46        // a centred or end-aligned row lines up with its siblings); any other
47        // trigger (a Link, a Box) fills the wrapper. A press Menu's trigger
48        // is its ONE child (no `trigger` slot).
49        let slot_trigger = if press_menu { node.children.first() } else { node.slots.as_ref().and_then(|s| s.get("trigger")) };
50        let fills = slot_trigger.is_some_and(|t| !matches!(t.component.as_str(), "Button" | "Toggle"));
51        if context_menu {
52            self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column"))]);
53        } else if fills {
54            self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("stretch"))]);
55        } else if slot_trigger.is_none() && !press_menu && kind != "Tooltip" {
56            // No trigger at all (a DockCard's sheet, a controlled Dialog): the
57            // wrapper lays out nothing, pinned to the start at zero size.
58            self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignSelf", json!("flex-start"))]);
59        } else {
60            self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("flex-start"))]);
61        }
62        let mut anchor: Option<u32> = None;
63        if let Some(trigger) = slot_trigger {
64            anchor = self.add(trigger, Some(index), layer_parent, scope, false);
65            if let Some(a) = anchor.filter(|_| fills) {
66                self.style_default(a, &[("flexGrow", json!(1)), ("minWidth", json!(0))]);
67            }
68        } else if press_menu {
69            // No child: the default outline Button from label / icon.
70            let label = owner.props.get("label").cloned().unwrap_or_else(|| Value::String(self.string("menu")));
71            let mut props = json!({"label": label, "variant": "outline"});
72            if let Some(icon) = owner.props.get("icon") {
73                props["icon"] = icon.clone();
74                if owner.props.get("label").is_none() {
75                    props["size"] = json!("icon");
76                }
77            }
78            let t = self.part(&owner, "trigger", "Button", NodeKind::Leaf, json!({}), props);
79            anchor = Some(t);
80        } else if kind == "Tooltip" {
81            // A tooltip wraps its children as the anchor.
82            let wrap = self.part(&owner, "anchor", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row"}), json!({}));
83            self.nodes[wrap as usize].part_query = None;
84            self.children_of(wrap, node, scope, false);
85            anchor = Some(wrap);
86        } else if context_menu {
87            // The target region: the child, inline.
88            self.children_of(index, node, scope, false);
89        }
90        let open_on_hover = kind == "Tooltip" || str_prop(&owner.props, "openOn") == Some("hover");
91        if let Some(a) = anchor {
92            self.nodes[a as usize].pressable |= !open_on_hover;
93            self.nodes[a as usize].trigger_for = Some(owner.id.clone());
94        }
95        let open = self.is_open(&owner.id, &owner.props, false);
96        if let Some(a) = anchor.filter(|_| open) {
97            self.add_state(a, "open");
98        }
99        if !open {
100            return;
101        }
102        let side = self.responsive_prop(&owner.props, "side").and_then(|v| v.as_str().and_then(OverlaySide::parse));
103        let point = self.ctx.local.context_point.get(&owner.id).copied();
104        let placement = match kind.as_str() {
105            "Dialog" => LayerPlacement::Centered,
106            "Drawer" => LayerPlacement::Edge(side.unwrap_or(OverlaySide::Bottom)),
107            "Tooltip" => LayerPlacement::Anchored { anchor: anchor.unwrap_or(index), side: side.unwrap_or(OverlaySide::Top), align: OverlayAlign::Center },
108            "Menu" if context_menu => match point {
109                Some((x, y)) => LayerPlacement::AtPoint { x, y },
110                None => LayerPlacement::Anchored { anchor: index, side: OverlaySide::Bottom, align: OverlayAlign::Start },
111            },
112            "Menu" => LayerPlacement::Anchored { anchor: anchor.unwrap_or(index), side: OverlaySide::Bottom, align: OverlayAlign::Start },
113            _ => LayerPlacement::Anchored { anchor: anchor.unwrap_or(index), side: side.unwrap_or(OverlaySide::Bottom), align: OverlayAlign::Center },
114        };
115        let content_style = match kind.as_str() {
116            "Dialog" => json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "padding": "$spacing.lg", "borderRadius": "$radius.xl", "width": "100%"}),
117            "Drawer" => match placement {
118                LayerPlacement::Edge(OverlaySide::Top | OverlaySide::Bottom) => json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "padding": "$spacing.lg", "width": "100%"}),
119                _ => json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "padding": "$spacing.lg", "height": "100%", "width": "100%"}),
120            },
121            "Tooltip" => json!({"display": "flex", "flexDirection": "row", "paddingHorizontal": "$spacing.sm", "paddingVertical": "$spacing.xs", "borderRadius": "$radius.md"}),
122            "Menu" => json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "minWidth": 160}),
123            _ => json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "padding": "$spacing.md", "borderRadius": "$radius.md"}),
124        };
125        let (layer, root) = self.layer_root(&owner, "content", content_style);
126        if matches!(kind.as_str(), "Dialog" | "Drawer") {
127            // Round 2: the dialog's name — its title, else `$string.dialog`.
128            let label = str_prop(&owner.props, "title").map(str::to_string).unwrap_or_else(|| self.string("dialog"));
129            self.default_a11y(root, "label", json!(label));
130        }
131        let owner_now = self.nodes[index as usize].clone();
132        let mut scroll_body = None;
133        let dismissible = owner.props.get("dismissible").and_then(Value::as_bool).unwrap_or(true);
134        match kind.as_str() {
135            "Dialog" | "Drawer" => {
136                if kind == "Drawer" && matches!(placement, LayerPlacement::Edge(OverlaySide::Bottom)) && owner.props.get("dragToDismiss").and_then(Value::as_bool).unwrap_or(true) {
137                    self.part_in(root, &owner_now, "handle", "Box", NodeKind::Container, json!({"width": 40, "height": 4, "borderRadius": "$radius.full", "alignSelf": "center", "flexShrink": 0}), json!({}), "");
138                }
139                if let Some(t) = str_prop(&owner.props, "title") {
140                    let t = t.to_string();
141                    let n = self.text_part(root, &owner_now, "title", &t, "title", "");
142                    self.nodes[n as usize].base_style.insert("flexShrink".into(), json!(0));
143                }
144                if let Some(d) = str_prop(&owner.props, "description") {
145                    let d = d.to_string();
146                    let n = self.text_part(root, &owner_now, "description", &d, "muted", "");
147                    self.nodes[n as usize].base_style.insert("flexShrink".into(), json!(0));
148                }
149                // The body scrolls when the content is taller than the viewport.
150                let body = self.part_in(root, &owner_now, "body", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.md", "flexShrink": 1, "minHeight": 0, "overflowY": "auto"}), json!({}), "");
151                self.nodes[body as usize].part_query = None;
152                scroll_body = Some(body);
153                self.children_of(body, node, scope, false);
154                if let Some(footer) = node.slots.as_ref().and_then(|s| s.get("footer")) {
155                    let f = self.part_in(root, &owner_now, "footer", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "justifyContent": "flex-end", "gap": "$spacing.sm", "flexShrink": 0}), json!({}), "");
156                    self.add(footer, Some(f), layer, scope, false);
157                }
158                if kind == "Dialog" && dismissible {
159                    let label = self.string("close");
160                    let close = self.part_in(root, &owner_now, "close", "Button", NodeKind::Leaf, json!({"position": "absolute", "top": "$spacing.sm", "insetInlineEnd": "$spacing.sm"}), json!({"icon": super::builtin_icon("Dialog.close"), "variant": "ghost", "size": "icon", "label": label}), "");
161                    self.nodes[close as usize].pressable = true;
162                }
163            }
164            "Tooltip" => {
165                let text = owner.props.get("content").map(js).unwrap_or_default();
166                self.text_part(root, &owner_now, "label", &text, "caption", "");
167            }
168            "Menu" => {
169                let items = owner.props.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
170                self.menu_items(root, &owner_now, &items, "", true);
171                scroll_body = Some(root);
172            }
173            _ => {
174                self.children_of(root, node, scope, false);
175            }
176        }
177        let modal = kind == "Dialog" || kind == "Drawer";
178        self.push_layer(layer, root, &owner_now, placement, modal, dismissible, false, scroll_body);
179    }
180
181    /// Menu entries (`menuItem.kind`): an action or checkbox ROW (check,
182    /// label, shortcut, submenu indicator), a separator, a group label; an
183    /// open submenu becomes its own layer beside its row.
184    pub(crate) fn menu_items(&mut self, parent: u32, owner: &LNode, items: &[Value], prefix: &str, top: bool) {
185        let open_sub = if top { self.ctx.local.submenu.get(&owner.id).copied() } else { None };
186        for (i, item) in items.iter().enumerate() {
187            let suffix = format!("{prefix}.{i}");
188            let kind = item.get("kind").and_then(Value::as_str).unwrap_or(if item.get("separator").and_then(Value::as_bool) == Some(true) { "separator" } else { "item" });
189            match kind {
190                "separator" => {
191                    self.part_in(parent, owner, "separator", "Box", NodeKind::Container, json!({"height": 1, "marginVertical": "$spacing.xs", "flexShrink": 0}), json!({}), &suffix);
192                }
193                "label" => {
194                    let text = item.get("label").map(js).unwrap_or_default();
195                    self.text_part(parent, owner, "label", &text, "caption", &suffix);
196                }
197                _ => {
198                    let disabled = item.get("disabled").and_then(Value::as_bool).unwrap_or(false);
199                    let checked = item.get("checked").and_then(Value::as_bool).unwrap_or(false);
200                    let label = item.get("label").map(js).unwrap_or_default();
201                    let mut props = json!({"text": label, "value": item.get("value").cloned().unwrap_or(Value::Null), "kind": kind, "disabled": disabled, "lines": 1});
202                    if kind == "checkbox" {
203                        props["checked"] = json!(checked);
204                    }
205                    if item.get("destructive").and_then(Value::as_bool) == Some(true) {
206                        props["destructive"] = json!(true);
207                    }
208                    let row = self.part_in(parent, owner, "item", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.sm"}), props, &suffix);
209                    self.nodes[row as usize].pressable = !disabled;
210                    if disabled {
211                        self.add_state(row, "disabled");
212                    }
213                    if kind == "checkbox" {
214                        if checked {
215                            self.add_state(row, "checked");
216                        }
217                        let glyph = format!("{}.check", owner.component);
218                        let c = self.icon_part(row, owner, "check", &glyph, &suffix);
219                        if !checked {
220                            self.nodes[c as usize].base_style.insert("visibility".into(), json!("hidden"));
221                        }
222                    }
223                    let mut label_props = json!({"text": label, "lines": 1});
224                    if let Some(icon) = item.get("icon") {
225                        label_props["icon"] = icon.clone();
226                    }
227                    let l = self.part_in(row, owner, "itemLabel", "Text", NodeKind::Leaf, json!({"flexGrow": 1, "flexShrink": 1, "minWidth": 0}), label_props, &suffix);
228                    self.nodes[l as usize].part_query = None;
229                    if let Some(shortcut) = item.get("shortcut").map(js) {
230                        self.text_part(row, owner, "shortcut", &shortcut, "muted", &suffix);
231                    }
232                    if kind == "submenu" {
233                        let glyph = format!("{}.submenuIndicator", owner.component);
234                        self.icon_part(row, owner, "submenuIndicator", &glyph, &suffix);
235                        if open_sub == Some(i) {
236                            self.add_state(row, "open");
237                            let sub_items = item.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
238                            // EXP-1259: a searchable submenu = the search list
239                            // (head, filtered rows, fade), its list scrolls.
240                            let searchable = item.get("searchable").and_then(Value::as_bool) == Some(true);
241                            let style = if searchable {
242                                json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "minWidth": 240})
243                            } else {
244                                json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "minWidth": 160})
245                            };
246                            let (layer, root) = self.layer_root_suffixed(owner, "content", &suffix, style);
247                            let scroll = if searchable {
248                                self.menu_search(root, owner, item, &suffix);
249                                self.slot_of_id(&format!("{}.list{suffix}", owner.id))
250                            } else {
251                                self.menu_items(root, owner, &sub_items, &suffix, false);
252                                Some(root)
253                            };
254                            self.push_layer(layer, root, owner, LayerPlacement::Anchored { anchor: row, side: OverlaySide::Right, align: OverlayAlign::Start }, false, true, false, scroll);
255                            if let Some(spec) = self.layers.last_mut() {
256                                spec.mirror = true;
257                            }
258                        }
259                    }
260                }
261            }
262        }
263    }
264
265    /// A Toast: hidden in the main tree; open ones go to the toast band.
266    pub(crate) fn toast(&mut self, index: u32, node: &UiNode) {
267        self.nodes[index as usize].hidden = true;
268        self.nodes[index as usize].kind = NodeKind::Leaf;
269        let open = self.is_open(&node.id, &self.nodes[index as usize].props.clone(), true);
270        if open {
271            self.pending_toasts.push((index, node.clone()));
272        }
273    }
274
275    /// The toast band: the newest three open toasts, newest nearest the edge.
276    pub(crate) fn flush_toasts(&mut self) {
277        let pending = std::mem::take(&mut self.pending_toasts);
278        let skip = pending.len().saturating_sub(MAX_TOASTS);
279        for (order, (index, _node)) in pending.into_iter().skip(skip).rev().enumerate() {
280            let owner = self.nodes[index as usize].clone();
281            let kind = str_prop(&owner.props, "type").unwrap_or("info").to_string();
282            let (layer, root) = self.layer_root(&owner, "root", json!({"display": "flex", "flexDirection": "row", "alignItems": "flex-start", "gap": "$spacing.sm", "padding": "$spacing.md", "borderRadius": "$radius.lg", "minWidth": 240, "maxWidth": 420}));
283            self.nodes[root as usize].live = Some(if kind == "error" { "assertive" } else { "polite" }.into());
284            self.query_prop(root, "type", Value::String(kind.clone()));
285            let glyph = format!("Toast.icon.{kind}");
286            self.icon_part(root, &owner, "icon", &glyph, "");
287            let body = self.part_in(root, &owner, "body", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.xxs", "flexGrow": 1, "flexShrink": 1, "minWidth": 0}), json!({}), "");
288            self.nodes[body as usize].part_query = None;
289            let title = owner.props.get("title").map(js).unwrap_or_default();
290            self.text_part(body, &owner, "title", &title, "label", "");
291            if let Some(d) = owner.props.get("description").map(js) {
292                self.text_part(body, &owner, "description", &d, "muted", "");
293            }
294            if let Some(label) = owner.props.get("actionLabel").map(js) {
295                let a = self.part_in(root, &owner, "action", "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"label": label, "variant": "outline", "size": "sm"}), "");
296                self.nodes[a as usize].pressable = true;
297                self.nodes[a as usize].own_query = Some(RecipeQuery::new("Button", "root", self.ctx.recipes.native_recipe_props("Button", &self.nodes[a as usize].props.clone()), Vec::new()));
298            }
299            let dismissible = owner.props.get("dismissible").and_then(Value::as_bool).unwrap_or(true);
300            if dismissible {
301                let label = self.string("dismiss");
302                let c = self.part_in(root, &owner, "close", "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"icon": super::builtin_icon("Toast.close"), "label": label, "variant": "ghost", "size": "icon"}), "");
303                self.nodes[c as usize].pressable = true;
304            }
305            self.push_layer(layer, root, &owner, LayerPlacement::Toast { order: order as u32 }, false, dismissible, false, None);
306            let duration = owner.props.get("duration").and_then(Value::as_f64).unwrap_or(5000.0);
307            self.toasts.push(ToastSpec { id: owner.id.clone(), duration_ms: duration, kind });
308        }
309        let _ = bool_prop;
310    }
311}