1use 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
16pub fn is_context_menu(component: &str, props: &crate::types::Props) -> bool {
19 component == "Menu" && str_prop(props, "openOn") == Some("contextmenu")
20}
21
22pub const MAX_TOASTS: usize = 3;
24
25impl Builder<'_, '_> {
26 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 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 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 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 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 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 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 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 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 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 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 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}