Skip to main content

eui_core/surface/
events.rs

1//! Interactions: local UI state (tabs, accordion, carousel, overlays,
2//! popups, calendars, menus, tables, chips, files, forms), write-through of
3//! bound props, `on` handlers (evaluated like `src/dynamic.ts runAction`:
4//! context and function args against the data AS IT IS, then `set`, then
5//! the event), scrolling and the host commands.
6
7use serde_json::{json, Map, Value};
8
9use super::{OutEvent, Surface, SurfaceCommand};
10use crate::data::{absolute_path, is_binding, run_action, set_pointer, ResolveContext};
11use crate::layout_tree::fields::{clamp_number_value, dates, number_prop, number_step_precision, parse_time};
12use crate::layout_tree::{FieldErrors, LNode, FORM_FIELDS};
13use crate::types::UiNode;
14
15fn js(v: &Value) -> String {
16    crate::json::to_js_string(v)
17}
18
19/// A check's resolved condition fails it (`failingChecks`: undefined,
20/// null, false, "").
21pub(crate) fn check_fails(v: Option<&Value>) -> bool {
22    matches!(v, None | Some(Value::Null) | Some(Value::Bool(false))) || v.and_then(Value::as_str) == Some("")
23}
24
25/// The index suffix of a part id (`<owner>.item.3` → 3).
26fn suffix_index(id: &str) -> Option<usize> {
27    id.rsplit('.').next().and_then(|s| s.parse().ok())
28}
29
30impl Surface {
31    // ------------------------------------------------------------------
32    // Scrolling
33    // ------------------------------------------------------------------
34
35    /// A scroll container scrolled vertically (content offset in px): any
36    /// `overflow: scroll|auto` node or a windowed List/Table (moves the
37    /// window; only the rows entering/leaving change).
38    pub fn scroll(&mut self, id: &str, offset: f32) -> bool {
39        let x = self.scroll_offset(id).0;
40        self.scroll_to(id, x, offset)
41    }
42
43    /// A scroll container named by its node id or its window key (a
44    /// Table's body or the Table) → the key its offset lives under.
45    fn scroll_id(&self, id: &str) -> String {
46        self.slot_of.get(id).map(|&s| self.scroll_key(s)).filter(|k| k != id && self.lists.iter().any(|l| l.id == *k)).unwrap_or_else(|| id.to_string())
47    }
48
49    /// Both axes. Offsets clamp to the content the last pass measured (a
50    /// fling past the end lands on the last page, never on blank rows).
51    pub fn scroll_to(&mut self, id: &str, x: f32, y: f32) -> bool {
52        let key = self.scroll_id(id);
53        let id = key.as_str();
54        let (mut x, mut y) = (x.max(0.0), y.max(0.0));
55        if let Some((max_x, max_y)) = self.scroll_max(id) {
56            x = x.min(max_x);
57            y = y.min(max_y);
58        }
59        let current = self.local.scroll.get(id).copied().unwrap_or((0.0, 0.0));
60        if (current.0 - x).abs() < 0.5 && (current.1 - y).abs() < 0.5 {
61            return false;
62        }
63        self.local.scroll.insert(id.to_string(), (x, y));
64        // A windowed container re-windows (its subtree only), and so does
65        // every windowed list this container is the viewport of (round 2);
66        // a plain one only moves paint.
67        let lists: Vec<String> = self
68            .lists
69            .iter()
70            .filter(|l| l.windowed)
71            .filter(|l| l.id == id || matches!(self.local.list_views.get(&l.id).map(|v| &v.source), Some(crate::layout_tree::ListViewSource::Ancestor { id: a, .. }) if a == id))
72            .map(|l| l.id.clone())
73            .collect();
74        self.subtree_pending.extend(lists);
75        // EXP-1259: a search list's bottom fade shows only while rows sit
76        // below its window.
77        if self.slot_of.get(id).is_some_and(|&s| self.nodes[s as usize].part.as_deref() == Some("list") && matches!(self.nodes[s as usize].owner_component.as_deref(), Some("SearchList" | "Menu"))) {
78            self.needs_build = true;
79            if !self.pending.iter().any(|e| matches!(e, OutEvent::Relayout)) {
80                self.pending.push(OutEvent::Relayout);
81            }
82        }
83        if (self.local.sticky_any || self.lists.iter().any(|l| l.sticky)) && !self.pending.iter().any(|e| matches!(e, OutEvent::Relayout)) {
84            // Sticky offsets follow the scroll.
85            self.pending.push(OutEvent::Relayout);
86        }
87        true
88    }
89
90    /// Round 2 (§5): the host's scroll offset of the WHOLE surface (a
91    /// surface laid out taller than its host viewport, the host scrolls
92    /// it). Lists without a bounded size window against it; sticky nodes
93    /// without a scrolling ancestor pin against it. Returns whether
94    /// anything must lay out again.
95    pub fn set_surface_scroll(&mut self, x: f32, y: f32) -> bool {
96        if self.local.surface_scroll == Some((x, y)) {
97            return false;
98        }
99        self.local.surface_scroll = Some((x, y));
100        let lists: Vec<String> = self.lists.iter().filter(|l| l.windowed && matches!(self.local.list_views.get(&l.id).map(|v| &v.source), Some(crate::layout_tree::ListViewSource::Host { .. }))).map(|l| l.id.clone()).collect();
101        let any = !lists.is_empty() || self.local.sticky_any;
102        self.subtree_pending.extend(lists);
103        any
104    }
105
106    /// The largest offsets of a scroll container as the last pass laid it
107    /// out (`None` before its first pass); an axis whose content fits is
108    /// not clamped (a windowed list inside a scrolling PAGE takes the page's
109    /// offset past its top).
110    fn scroll_max(&self, id: &str) -> Option<(f32, f32)> {
111        let slot = self.lists.iter().find(|l| l.id == id).map(|l| l.node).or_else(|| self.slot_of.get(id).copied())?;
112        let s = self.scrolls.iter().find(|s| s.index == slot)?;
113        let f = self.last_frames.get(slot as usize)?;
114        let max = |content: f32, size: f32, on: bool| if on && content > size + 0.5 { content - size } else { f32::INFINITY };
115        Some((max(s.content_width, f.w, s.scroll_x), max(s.content_height, f.h, s.scroll_y)))
116    }
117
118    /// The key a scroll container's offset is kept under: a windowed
119    /// Table's BODY scrolls under the Table's id (its window key), every
120    /// other container under its own id.
121    pub(super) fn scroll_key(&self, slot: u32) -> String {
122        self.lists.iter().find(|l| l.node == slot).map(|l| l.id.clone()).unwrap_or_else(|| self.nodes[slot as usize].id.clone())
123    }
124
125    /// The current offset of a scroll container.
126    pub fn scroll_offset(&self, id: &str) -> (f32, f32) {
127        self.local.scroll.get(&self.scroll_id(id)).copied().unwrap_or((0.0, 0.0))
128    }
129
130    // ------------------------------------------------------------------
131    // Overlays
132    // ------------------------------------------------------------------
133
134    /// Open or close an overlay or popup (Dialog, Drawer, Popover, Tooltip,
135    /// Menu, Select, the pickers, Toast).
136    pub fn set_open(&mut self, id: &str, open: bool) -> Vec<OutEvent> {
137        self.ensure_reduced();
138        self.local.open.insert(id.to_string(), open);
139        if !open {
140            self.local.submenu.remove(id);
141            self.local.query.remove(id);
142            self.local.range_anchor.remove(id);
143        }
144        self.needs_build = true;
145        let mut out = vec![];
146        let written = self.write_through(id, "open", Value::Bool(open));
147        // A bound `open` now holds the value: the local copy would outlive it
148        // and shadow the next `set` from the data model (a dismissed Drawer
149        // that never reopens).
150        if !written.is_empty() {
151            self.local.open.remove(id);
152        }
153        out.extend(written);
154        out.extend(self.fire(id, "change", Some(json!({"open": open}))));
155        out.push(OutEvent::Relayout);
156        out
157    }
158
159    fn is_open_now(&self, id: &str) -> bool {
160        self.local.open.get(id).copied().unwrap_or_else(|| self.node_by_id(id).and_then(|o| o.props.get("open").and_then(Value::as_bool)).unwrap_or(false))
161    }
162
163    /// A Toast's timeout (or its close button): `open: false`, `dismiss` +
164    /// `change`.
165    pub fn dismiss_toast(&mut self, id: &str) -> Vec<OutEvent> {
166        self.ensure_reduced();
167        let mut out = self.fire(id, "dismiss", None);
168        out.extend(self.set_open(id, false));
169        out
170    }
171
172    // ------------------------------------------------------------------
173    // Lookup
174    // ------------------------------------------------------------------
175
176    pub(super) fn node_by_id(&self, id: &str) -> Option<&LNode> {
177        self.slot_of.get(id).map(|s| &self.nodes[*s as usize])
178    }
179
180    /// The SOURCE node of `id` (its `on`, its raw bindings, its `checks`),
181    /// renamed to `id`: an instance node (inside a template row or a Table
182    /// slot cell, at any depth) resolves through the source the build
183    /// recorded on it.
184    fn source_node(&self, id: &str) -> Option<UiNode> {
185        let source = self.node_by_id(id).and_then(|n| n.source.as_ref());
186        let (source_id, tree) = match source {
187            Some(s) => (s.id.as_str(), match &s.template {
188                Some(t) => self.template_cache.get(t)?.as_ref()?,
189                None => self.root.as_ref()?,
190            }),
191            None => (id, self.root.as_ref()?),
192        };
193        let mut found = None;
194        tree.walk(&mut |n| {
195            if found.is_none() && n.id == source_id {
196                found = Some(n.clone());
197            }
198        });
199        found.map(|mut n| {
200            n.id = id.to_string();
201            n
202        })
203    }
204
205    /// The instance suffix of `id` (what its build appended to the source
206    /// id; empty for a node of the surface's tree).
207    fn instance_suffix(&self, id: &str) -> String {
208        self.node_by_id(id).and_then(|n| n.source.as_ref()).and_then(|s| id.strip_prefix(s.id.as_str())).unwrap_or("").to_string()
209    }
210
211    fn scope_of(&self, id: &str) -> String {
212        self.node_by_id(id).map(|n| n.scope.clone()).unwrap_or_default()
213    }
214
215    /// The resolve context of a scope: the data model, the strings and, for
216    /// a literal Table row's scope, the row standing in for its pointer.
217    fn resolve_ctx<'s>(&'s self, scope: &'s str) -> ResolveContext<'s> {
218        let ctx = ResolveContext::new(&self.data, scope).with_strings(&self.strings).with_formatter(&*self.formatter);
219        let ctx = match self.clock {
220            Some(now) => ctx.with_now(now),
221            None => ctx,
222        };
223        match crate::layout_tree::row_scope_root(scope).and_then(|root| self.seed.row_scopes.get_key_value(root)) {
224            Some((root, row)) => ctx.with_overlay(root, row),
225            None => ctx,
226        }
227    }
228
229    /// The binding path of `prop` on `id`, made absolute. A RELATIVE path
230    /// inside a literal Table row (`/$row/…`) has no place in the data model:
231    /// the control keeps its value locally like an unbound one.
232    fn binding_path(&self, id: &str, prop: &str) -> Option<String> {
233        let node = self.source_node(id)?;
234        let binding = node.props.get(prop).filter(|v| is_binding(v))?;
235        Some(absolute_path(binding["path"].as_str().unwrap_or(""), &self.scope_of(id))).filter(|p| !p.starts_with("/$row/"))
236    }
237
238    /// If `prop` on `id` is bound, write `value` to the data model.
239    fn write_through(&mut self, id: &str, prop: &str, value: Value) -> Vec<OutEvent> {
240        let Some(path) = self.binding_path(id, prop) else { return vec![] };
241        // A refused write (VAPP-103 pointer limits) keeps the value locally.
242        if set_pointer(&mut self.data, &path, Some(value.clone())).is_err() {
243            return vec![];
244        }
245        self.data_version += 1;
246        self.needs_build = true;
247        vec![OutEvent::DataChanged { path, value }]
248    }
249
250    /// Write a field's new value: through its binding, else into the local
251    /// field state (so the control shows it and a Form collects it).
252    fn write_field(&mut self, id: &str, prop: &str, value: Value) -> Vec<OutEvent> {
253        let out = self.write_through(id, prop, value.clone());
254        if out.is_empty() {
255            self.local.field_values.insert(id.to_string(), value);
256            self.needs_build = true;
257        } else {
258            self.local.field_values.remove(id);
259        }
260        out
261    }
262
263    /// Fire a node's `on.<event>` handler. The component's OWN write has
264    /// already landed (round 4, `src/dynamic.ts withOwnWrites`: every caller
265    /// writes EXACTLY what the component wrote — `write_through` /
266    /// `write_field` — before it fires; nothing is inferred from the
267    /// payload, whose keys may name other things: an `upload`'s `added`),
268    /// so the context and the function's args resolve against that data,
269    /// then `set` writes, then the event goes out with the payload merged
270    /// into its context.
271    pub(super) fn fire(&mut self, id: &str, event: &str, payload: Option<Value>) -> Vec<OutEvent> {
272        let Some(node) = self.source_node(id) else { return vec![] };
273        let Some(action) = node.on.as_ref().and_then(|o| o.get(event)).cloned() else { return vec![] };
274        let mut out = vec![];
275        let scope = self.scope_of(id);
276        // VAPP-206: a Table's `rowPress` resolves in the pressed row: a
277        // relative `{path: "id"}` reads it.
278        let pressed = if event == "rowPress" { payload.as_ref().and_then(|p| p.get("row")).cloned() } else { None };
279        let outcome = {
280            let ctx = self.resolve_ctx(&scope);
281            let ctx = match &pressed {
282                Some(row) => ctx.with_item(row),
283                None => ctx,
284            };
285            run_action(&action, &ctx)
286        };
287        if let Some((path, value)) = outcome.written {
288            self.data = outcome.data;
289            self.data_version += 1;
290            self.needs_build = true;
291            out.push(OutEvent::DataChanged { path, value });
292        }
293        if let Some(ev) = outcome.event {
294            let mut context = match ev.context {
295                Some(Value::Object(m)) => m,
296                _ => Map::new(),
297            };
298            if let Some(Value::Object(p)) = &payload {
299                for (k, v) in p {
300                    context.insert(k.clone(), v.clone());
301                }
302            }
303            out.push(OutEvent::Action { name: ev.name, component_id: id.to_string(), event: event.to_string(), context: Value::Object(context), payload });
304        }
305        if let Some(call) = outcome.call {
306            if call.call == "openUrl" {
307                if let Some(url) = call.args.get("url").and_then(Value::as_str) {
308                    out.push(OutEvent::OpenUrl { url: url.to_string() });
309                }
310            } else if !call.call.is_empty() && (call.call == "bind" || !crate::host::is_builtin_function(&call.call)) {
311                // A host function: the host's registry + policy gate decide
312                // (VAPP-91). `bind` is a built-in the HOST runs (it re-follows
313                // a source). The other built-ins are value functions: no effect.
314                out.push(OutEvent::FunctionCall { component_id: id.to_string(), name: call.call, args: Value::Object(call.args) });
315            }
316        }
317        out
318    }
319
320    // ------------------------------------------------------------------
321    // Events
322    // ------------------------------------------------------------------
323
324    /// An interaction on layout node `index`: `press`, `change` (with a
325    /// payload such as `{value}` / `{open}` / `{page}`), `select`, `submit`,
326    /// `commit`, `dismiss`, `contextmenu {x, y}`, `upload {files}`. Local UI
327    /// state is updated here; bound props write through; `on` handlers fire.
328    pub fn event(&mut self, index: u32, event: &str, payload: Option<Value>) -> Vec<OutEvent> {
329        self.ensure_reduced();
330        if !self.live.get(index as usize).copied().unwrap_or(false) {
331            return vec![];
332        }
333        let n = self.nodes[index as usize].clone();
334        // A disabled (or loading) control is inert whatever the host
335        // dispatches (keyboard activation, an accessibility action): only
336        // closing and leaving go through.
337        if !matches!(event, "dismiss" | "blur" | "reset") && self.is_inert(&n) {
338            return vec![];
339        }
340        if event == "contextmenu" {
341            return self.context_menu(&n, payload);
342        }
343        // VAPP-162: a `hold` Button never fires `press` (Surface::hold drives it).
344        if event == "press" && n.component == "Button" && n.part.is_none() && n.props.get("hold").and_then(Value::as_bool) == Some(true) {
345            return vec![];
346        }
347        if event == "press" {
348            if let Some(target) = n.trigger_for.clone() {
349                let mut out = self.fire(&n.id, "press", None);
350                let open = !self.is_open_now(&target);
351                out.extend(self.set_open(&target, open));
352                return out;
353            }
354        }
355        let owner_id = n.owner.clone().unwrap_or_else(|| n.id.clone());
356        let owner_component = n.owner_component.clone().unwrap_or_else(|| n.component.clone());
357        let part = n.part.clone();
358        let mut out = vec![];
359        // #EXP-1674: a Button's or a Row's (its root Box) `href` opens like a
360        // Link's, ahead of the press event; the host's URL policy decides.
361        if event == "press" && n.part.is_none() && matches!(n.component.as_str(), "Box" | "Button") {
362            if let Some(url) = n.props.get("href").and_then(Value::as_str).filter(|url| !url.is_empty()) {
363                out.push(OutEvent::OpenUrl { url: url.to_string() });
364            }
365        }
366        match (owner_component.as_str(), part.as_deref(), event) {
367            ("Tabs", Some("tab"), "press") => {
368                let value = n.props.get("value").map(js).unwrap_or_default();
369                self.local.tabs.insert(owner_id.clone(), value.clone());
370                self.subtree_pending.insert(owner_id.clone());
371                out.extend(self.write_through(&owner_id, "value", Value::String(value.clone())));
372                out.extend(self.fire(&owner_id, "change", Some(json!({"value": value}))));
373                out.push(OutEvent::Relayout);
374            }
375            ("Accordion", Some("trigger"), "press") => {
376                let Some(owner) = self.node_by_id(&owner_id).cloned() else { return out };
377                let value = n.props.get("value").map(js).unwrap_or_default();
378                let multiple = owner.props.get("type").and_then(Value::as_str) == Some("multiple");
379                let mut open = self.local.accordion.get(&owner_id).cloned().unwrap_or_else(|| owner.props.get("value").map(js).map(|v| v.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect()).unwrap_or_default());
380                if open.contains(&value) {
381                    open.retain(|v| *v != value);
382                } else if multiple {
383                    open.push(value.clone());
384                } else {
385                    open = vec![value.clone()];
386                }
387                let joined = open.join(",");
388                self.local.accordion.insert(owner_id.clone(), open);
389                self.subtree_pending.insert(owner_id.clone());
390                out.extend(self.write_through(&owner_id, "value", Value::String(joined.clone())));
391                out.extend(self.fire(&owner_id, "change", Some(json!({"value": joined}))));
392                out.push(OutEvent::Relayout);
393            }
394            ("Carousel", _, "change") | ("Carousel", Some("previous" | "next" | "thumbnail"), "press") => {
395                // A previous/next button or a thumbnail carries the page it goes to.
396                let page = if event == "press" { n.props.get("target").and_then(Value::as_i64) } else { payload.as_ref().and_then(|p| p.get("page")).and_then(Value::as_i64) }.unwrap_or(0);
397                self.local.carousel.insert(owner_id.clone(), page);
398                self.subtree_pending.insert(owner_id.clone());
399                out.extend(self.write_through(&owner_id, "page", json!(page)));
400                out.extend(self.fire(&owner_id, "change", Some(json!({"page": page}))));
401                out.push(OutEvent::Relayout);
402            }
403            ("Menu", Some("item"), "press") => out.extend(self.menu_select(&n, &owner_id)),
404            ("Toast", Some("action"), "press") => {
405                out.extend(self.fire(&owner_id, "action", None));
406                out.extend(self.dismiss_toast(&owner_id));
407            }
408            ("Toast", Some("close"), "press") | ("Toast", _, "dismiss") => out.extend(self.dismiss_toast(&owner_id)),
409            ("Dialog" | "Drawer" | "Popover" | "Tooltip" | "Menu", part, "press") if part.is_some() || n.id == owner_id => {
410                if part == Some("close") {
411                    out.extend(self.set_open(&owner_id, false));
412                } else if part.is_none() || part == Some("trigger") {
413                    let open = !self.is_open_now(&owner_id);
414                    out.extend(self.set_open(&owner_id, open));
415                }
416            }
417            (_, _, "dismiss") => {
418                // Escape, a scrim press, a drag: close the overlay if allowed.
419                let dismissible = self.layers.iter().find(|l| l.owner == owner_id).map(|l| l.dismissible).unwrap_or(true);
420                if dismissible {
421                    out.extend(self.set_open(&owner_id, false));
422                }
423            }
424            ("Dialog" | "Drawer" | "Popover" | "Tooltip" | "Menu" | "Select" | "DatePicker" | "DateRangePicker" | "TimePicker", _, "change") if payload.as_ref().and_then(|p| p.get("open")).is_some() => {
425                let open = payload.as_ref().and_then(|p| p.get("open")).and_then(Value::as_bool).unwrap_or(false);
426                out.extend(self.set_open(&owner_id, open));
427            }
428            ("Select", Some("item"), "press") => out.extend(self.select_option(&n, &owner_id)),
429            // EXP-1259: the Tabs close glyph and the + button.
430            ("Tabs", Some("close"), "press") => {
431                let value = n.props.get("value").cloned().unwrap_or(Value::Null);
432                out.extend(self.fire(&owner_id, "close", Some(json!({"value": value}))));
433            }
434            ("Tabs", Some("new"), "press") => out.extend(self.fire(&owner_id, "new", None)),
435            // VAPP-279: a strip's `+N`, the reorder drag and keyboard move.
436            ("Tabs", Some("more"), "press") => out.extend(self.tab_more(&owner_id)),
437            ("Tabs", Some("tab"), "drag") if self.is_tab_strip(&owner_id) => out.extend(self.tab_drag(&n.id, &owner_id, payload.as_ref())),
438            ("Tabs", Some("tab"), "key") if self.is_tab_strip(&owner_id) => out.extend(self.tab_key(&n.id, &owner_id, payload.as_ref())),
439            // EXP-1259: the SearchList field and rows.
440            ("SearchList", Some("search"), "change") => {
441                let query = payload.as_ref().and_then(|p| p.get("value")).map(js).unwrap_or_default();
442                out.extend(self.search_query(&owner_id, &owner_id, &format!("{owner_id}.list"), query.clone()));
443                out.extend(self.write_through(&owner_id, "query", Value::String(query.clone())));
444                out.extend(self.fire(&owner_id, "search", Some(json!({"query": query}))));
445            }
446            ("SearchList", Some("search"), "key") => {
447                let key = payload.as_ref().and_then(|p| p.get("key")).map(js).unwrap_or_default();
448                out.extend(self.search_key(&owner_id, &owner_id, &format!("{owner_id}.list"), &key));
449            }
450            ("SearchList", Some("item"), "press") => out.extend(self.search_pick(&n, &owner_id)),
451            ("Menu", Some("search"), "change") => {
452                let query = payload.as_ref().and_then(|p| p.get("value")).map(js).unwrap_or_default();
453                let list = format!("{owner_id}.list{}", n.id.get(owner_id.len() + ".search".len()..).unwrap_or_default());
454                out.extend(self.search_query(&n.id, &owner_id, &list, query));
455            }
456            ("Menu", Some("search"), "key") => {
457                let key = payload.as_ref().and_then(|p| p.get("key")).map(js).unwrap_or_default();
458                if key == "ArrowLeft" {
459                    // Back to the parent menu (the field's caret sits at its start).
460                    self.local.submenu.remove(&owner_id);
461                    self.needs_build = true;
462                    out.push(OutEvent::Relayout);
463                } else {
464                    let list = format!("{owner_id}.list{}", n.id.get(owner_id.len() + ".search".len()..).unwrap_or_default());
465                    out.extend(self.search_key(&n.id, &owner_id, &list, &key));
466                }
467            }
468            ("Select", Some("search"), "change") => {
469                let query = payload.as_ref().and_then(|p| p.get("value")).map(js).unwrap_or_default();
470                self.local.query.insert(owner_id.clone(), query.clone());
471                self.needs_build = true;
472                out.extend(self.fire(&owner_id, "search", Some(json!({"query": query}))));
473                out.push(OutEvent::Relayout);
474            }
475            ("DatePicker" | "DateRangePicker", Some("previous" | "next"), "press") => {
476                let delta = if part.as_deref() == Some("next") { 1 } else { -1 };
477                let current = self.calendar_month(&owner_id);
478                let (y, m) = dates::shift_month(current.0, current.1, delta);
479                self.local.month.insert(owner_id.clone(), (y, m));
480                self.needs_build = true;
481                // #VAPP-282 (fixtures/month-grid.json): the view moved.
482                if owner_component == "DatePicker" {
483                    out.extend(self.fire(&owner_id, "month", Some(json!({"month": format!("{y:04}-{m:02}")}))));
484                }
485                out.push(OutEvent::Relayout);
486            }
487            ("DatePicker", Some("day"), "press") => {
488                let date = n.props.get("date").map(js).unwrap_or_default();
489                out.extend(self.write_field(&owner_id, "value", Value::String(date.clone())));
490                out.extend(self.fire(&owner_id, "change", Some(json!({"value": date}))));
491                // An inline grid has no popup to close.
492                if self.node_by_id(&owner_id).is_some_and(|o| o.props.get("inline").and_then(Value::as_bool) == Some(true)) {
493                    self.needs_build = true;
494                    out.push(OutEvent::Relayout);
495                } else {
496                    out.extend(self.set_open(&owner_id, false));
497                }
498            }
499            ("DateRangePicker", Some("day"), "press") => out.extend(self.range_pick(&n, &owner_id)),
500            ("TimePicker", Some("item"), "press") => {
501                let value = n.props.get("value").map(js).unwrap_or_default();
502                out.extend(self.write_field(&owner_id, "value", Value::String(value.clone())));
503                out.extend(self.fire(&owner_id, "change", Some(json!({"value": value}))));
504                out.extend(self.set_open(&owner_id, false));
505            }
506            ("NumberField", Some("decrement" | "increment"), "press") => {
507                let up = part.as_deref() == Some("increment");
508                out.extend(self.number_step(&owner_id, up));
509            }
510            ("NumberField", _, "submit") => {
511                out.extend(self.fire(&owner_id, "submit", payload.clone()));
512                if let Some(form) = n.form.clone() {
513                    out.extend(self.submit_form(&form));
514                }
515            }
516            ("NumberField", _, "change" | "commit") => {
517                // Text parses in the surface formatter's separators and
518                // digits (what the field shows); unreadable text changes
519                // nothing (the reference's NumberField), empty text clears.
520                let raw = payload.as_ref().and_then(|p| p.get("value")).cloned().unwrap_or(Value::Null);
521                let parsed = match &raw {
522                    Value::Number(_) => raw.as_f64(),
523                    Value::String(s) => match crate::format::parse_number(self.formatter.as_ref(), s) {
524                        None if !s.trim().is_empty() => return out,
525                        v => v,
526                    },
527                    _ => None,
528                };
529                let value = parsed.map(|v| if event == "commit" { self.clamp_number(&owner_id, v) } else { v });
530                let v = value.map(crate::json::number).unwrap_or(Value::Null);
531                self.local.errors.remove(&owner_id);
532                out.extend(self.write_field(&owner_id, "value", v.clone()));
533                out.extend(self.fire(&owner_id, "change", Some(json!({"value": v}))));
534                out.push(OutEvent::Relayout);
535            }
536            ("ChipInput", Some("input"), "change") => {
537                let text = payload.as_ref().and_then(|p| p.get("value")).map(js).unwrap_or_default();
538                if let Some(entry) = text.strip_suffix(',') {
539                    out.extend(self.chip_add(&owner_id, entry.trim()));
540                } else {
541                    self.local.query.insert(owner_id.clone(), text);
542                    self.needs_build = true;
543                    out.push(OutEvent::Relayout);
544                }
545            }
546            ("ChipInput", Some("input"), "submit" | "commit") => {
547                let text = payload.as_ref().and_then(|p| p.get("value")).map(js).unwrap_or_default();
548                out.extend(self.chip_add(&owner_id, text.trim()));
549            }
550            ("ChipInput", Some("suggestion"), "press") => {
551                let value = n.props.get("value").map(js).unwrap_or_default();
552                out.extend(self.chip_add(&owner_id, &value));
553            }
554            ("ChipInput", Some("remove"), "press") => {
555                let value = n.props.get("value").map(js).unwrap_or_default();
556                out.extend(self.chip_remove(&owner_id, &value));
557            }
558            ("FileUpload", Some("dropzone" | "browse"), "press") => {
559                let owner = self.node_by_id(&owner_id).cloned();
560                let accept = owner.as_ref().and_then(|o| o.props.get("accept")).and_then(Value::as_str).map(str::to_string);
561                let multiple = owner.as_ref().and_then(|o| o.props.get("multiple")).and_then(Value::as_bool).unwrap_or(false);
562                out.push(OutEvent::PickFiles { component_id: owner_id.clone(), accept, multiple });
563            }
564            ("FileUpload", _, "upload") => out.extend(self.files_upload(&owner_id, payload)),
565            ("FileUpload", Some("remove"), "press") => {
566                let i = suffix_index(&n.id).unwrap_or(0);
567                out.extend(self.file_remove(&owner_id, i));
568            }
569            ("Table", Some("headerCell"), "press") => out.extend(self.table_sort(&n, &owner_id)),
570            ("Table", Some("row"), "press") => out.extend(self.table_row(&n, &owner_id)),
571            ("Table", Some("checkbox"), "press") => out.extend(self.table_check(&n, &owner_id)),
572            ("CodeBlock", Some("copy"), "press") => {
573                // EXP-1324: the text the block shows (`ansi`: escapes removed).
574                let owner = self.node_by_id(&owner_id);
575                let raw = owner.and_then(|o| o.props.get("code")).map(js).unwrap_or_default();
576                let language = owner.and_then(|o| o.props.get("language")).and_then(Value::as_str).unwrap_or("plain").to_string();
577                let code = crate::code::code_text(&raw, &language);
578                self.local.copied.insert(owner_id.clone(), true);
579                self.subtree_pending.insert(owner_id.clone());
580                out.push(OutEvent::Copy { text: code });
581                out.push(OutEvent::Announce { text: self.strings.get("copied").cloned().unwrap_or_default(), live: "polite".into() });
582                out.push(OutEvent::Relayout);
583            }
584            ("CodeBlock", Some("copy"), "reset") => {
585                self.local.copied.remove(&owner_id);
586                self.subtree_pending.insert(owner_id.clone());
587                out.push(OutEvent::Relayout);
588            }
589            ("Resizable", Some("handle"), "drag" | "key") => out.extend(self.resize_event(&n, &owner_id, event, payload.as_ref())),
590            ("Checkbox" | "Switch", _, "press" | "change") => {
591                let current = self.current_prop(&owner_id, "checked").and_then(|v| v.as_bool()).unwrap_or(false);
592                let checked = payload.as_ref().and_then(|p| p.get("checked")).and_then(Value::as_bool).unwrap_or(!current);
593                self.local.errors.remove(&owner_id);
594                out.extend(self.write_field(&owner_id, "checked", Value::Bool(checked)));
595                out.extend(self.fire(&owner_id, "change", Some(json!({"checked": checked}))));
596                out.push(OutEvent::Relayout);
597            }
598            ("Radio", Some("item" | "dot" | "label"), "press") => {
599                // The option index is the id suffix (`<owner>.dot.2`); the
600                // ROW (`<owner>.item.2`) carries the option's value.
601                let suffix = n.id.rsplit('.').next().unwrap_or_default();
602                let row_id = format!("{owner_id}.item.{suffix}");
603                let value = self.node_by_id(&row_id).and_then(|row| row.props.get("value").cloned()).unwrap_or(Value::Null);
604                self.local.errors.remove(&owner_id);
605                out.extend(self.write_field(&owner_id, "value", value.clone()));
606                out.extend(self.fire(&owner_id, "change", Some(json!({"value": value}))));
607                out.push(OutEvent::Relayout);
608            }
609            ("ChoiceTiles", Some("tile" | "picture" | "label" | "description"), "press") => {
610                // EXP-1280: the option index is the id suffix
611                // (`<owner>.label.2`); the TILE (`<owner>.tile.2`) carries the
612                // option's value and whether it is disabled.
613                let suffix = n.id.rsplit('.').next().unwrap_or_default();
614                let tile = self.node_by_id(&format!("{owner_id}.tile.{suffix}")).cloned();
615                let Some(tile) = tile.filter(|t| t.props.get("disabled").and_then(Value::as_bool) != Some(true)) else { return out };
616                let value = tile.props.get("value").map(js).unwrap_or_default();
617                let mut props = self.node_by_id(&owner_id).map(|o| o.props.clone()).unwrap_or_default();
618                if let Some(current) = self.current_prop(&owner_id, "value") {
619                    props.insert("value".into(), current);
620                }
621                let next = Value::String(crate::layout_tree::fields::choice_toggle(&props, &value));
622                self.local.errors.remove(&owner_id);
623                out.extend(self.write_field(&owner_id, "value", next.clone()));
624                out.extend(self.fire(&owner_id, "change", Some(json!({"value": next}))));
625                out.push(OutEvent::Relayout);
626            }
627            ("Toggle", _, "press") => {
628                let current = self.local.pressed_toggles.get(&owner_id).copied().unwrap_or_else(|| n.props.get("pressed").and_then(Value::as_bool).unwrap_or(false));
629                self.local.pressed_toggles.insert(owner_id.clone(), !current);
630                self.subtree_pending.insert(owner_id.clone());
631                out.extend(self.write_through(&owner_id, "pressed", Value::Bool(!current)));
632                out.extend(self.fire(&owner_id, "change", Some(json!({"pressed": !current}))));
633                out.push(OutEvent::Relayout);
634            }
635            ("Link", _, "press") => {
636                if let Some(url) = n.props.get("href").and_then(Value::as_str) {
637                    out.push(OutEvent::OpenUrl { url: url.to_string() });
638                }
639                out.extend(self.fire(&owner_id, "press", None));
640            }
641            ("Button", None, "press") if n.props.get("submit").and_then(Value::as_bool) == Some(true) => {
642                out.extend(self.fire(&owner_id, "press", None));
643                if let Some(form) = n.form.clone() {
644                    out.extend(self.submit_form(&form));
645                }
646            }
647            ("Input" | "Textarea" | "Select" | "DatePicker" | "Slider" | "Composer" | "Segmented" | "ChoiceTiles" | "TimePicker" | "DateRangePicker", _, "change" | "commit" | "submit") => {
648                let value = payload.as_ref().and_then(|p| p.get("value")).cloned().unwrap_or(Value::Null);
649                let name = n.props.get("name").or_else(|| self.node_by_id(&owner_id).and_then(|o| o.props.get("name"))).and_then(Value::as_str).unwrap_or(&owner_id).to_string();
650                let path = self.binding_path(&owner_id, "value");
651                // A submit carries the text as it stands: written only when
652                // it differs (the own write lands before the action fires).
653                if event != "submit" || self.current_prop(&owner_id, "value").as_ref() != Some(&value) {
654                    out.extend(self.write_field(&owner_id, "value", value.clone()));
655                    // `validateOn: change` checks as the user types; a field
656                    // already showing errors re-checks on every change.
657                    if event == "change" && (self.validate_on(&owner_id) == "change" || self.local.errors.contains_key(&owner_id)) {
658                        self.validate_field(&owner_id);
659                    }
660                }
661                out.push(OutEvent::Input { component_id: owner_id.clone(), name, path, value: value.clone(), commit: event != "change" });
662                out.extend(self.fire(&owner_id, event, Some(json!({"value": value}))));
663                // Enter in a single-line field submits its Form.
664                if event == "submit" && owner_component != "Textarea" {
665                    if let Some(form) = n.form.clone() {
666                        out.extend(self.submit_form(&form));
667                    }
668                }
669            }
670            // EXP-1586: a settled zoom writes the bound `scale` (two-way),
671            // then fires `zoom {scale, atFit}`.
672            ("ZoomableImage", None, "zoom") => {
673                if let Some(scale) = payload.as_ref().and_then(|p| p.get("scale")).filter(|s| s.is_number()).cloned() {
674                    out.extend(self.write_through(&owner_id, "scale", scale));
675                }
676                out.extend(self.fire(&owner_id, "zoom", payload));
677            }
678            ("Form", _, "submit") => out.extend(self.submit_form(&owner_id)),
679            ("Input" | "Textarea" | "NumberField" | "Select" | "DatePicker" | "TimePicker" | "ChipInput", _, "blur") => {
680                if self.validate_on(&owner_id) == "blur" {
681                    self.validate_field(&owner_id);
682                    out.push(OutEvent::Relayout);
683                }
684                out.extend(self.fire(&owner_id, "blur", None));
685            }
686            _ => {
687                out.extend(self.fire(&owner_id, event, payload));
688            }
689        }
690        out
691    }
692
693    /// The node, or the native owning it, is `disabled`/`loading` (its own
694    /// props, a Form's injection, or the host's `disabled` state).
695    /// VAPP-162 (round-5 contract §9): one pointer / key / focus input on a
696    /// `hold: true` Button at layout node `index` ([`crate::hold::HoldInput`],
697    /// coordinates relative to the button's top-left; the box is the last
698    /// laid-out frame). Fires `holdStart` / `holdEnd` / `holdCancel`, and
699    /// while held the node carries the `pressed` state and paints its
700    /// `holdingLabel` (a relayout follows every start and stop). Anything
701    /// but an enabled hold Button: nothing.
702    pub fn hold(&mut self, index: u32, input: &crate::hold::HoldInput) -> Vec<OutEvent> {
703        self.ensure_reduced();
704        if !self.live.get(index as usize).copied().unwrap_or(false) {
705            return vec![];
706        }
707        let n = self.nodes[index as usize].clone();
708        if n.component != "Button" || n.part.is_some() || n.props.get("hold").and_then(Value::as_bool) != Some(true) {
709            return vec![];
710        }
711        if self.is_inert(&n) {
712            // A button disabled mid-hold drops the hold silently.
713            if self.local.holds.remove(&n.id).is_some_and(|t| t.active()) {
714                self.subtree_pending.insert(n.id.clone());
715                return vec![OutEvent::Relayout];
716            }
717            return vec![];
718        }
719        let frame = self.last_frame(index).unwrap_or_default();
720        let tracker = self.local.holds.entry(n.id.clone()).or_default();
721        let Some(fired) = tracker.input(input, frame.w, frame.h) else { return vec![] };
722        if !tracker.active() {
723            self.local.holds.remove(&n.id);
724        }
725        self.subtree_pending.insert(n.id.clone());
726        let mut out = self.fire(&n.id, fired.name(), None);
727        out.push(OutEvent::Relayout);
728        out
729    }
730
731    /// Whether a `hold` Button is held now.
732    pub fn is_held(&self, id: &str) -> bool {
733        self.local.holds.get(id).is_some_and(crate::hold::HoldTracker::active)
734    }
735
736    fn is_inert(&self, n: &LNode) -> bool {
737        let flagged = |p: &crate::types::Props| p.get("disabled").and_then(Value::as_bool) == Some(true) || p.get("loading").and_then(Value::as_bool) == Some(true);
738        if flagged(&n.props) || self.states.get(&n.id).is_some_and(|s| s.iter().any(|x| x == "disabled")) {
739            return true;
740        }
741        n.owner.as_ref().and_then(|o| self.node_by_id(o)).is_some_and(|o| flagged(&o.props))
742    }
743
744    fn context_menu(&mut self, n: &LNode, payload: Option<Value>) -> Vec<OutEvent> {
745        // The nearest context Menu (`openOn: contextmenu`) at or above the node.
746        let mut cur = Some(n.index);
747        while let Some(c) = cur {
748            let node = &self.nodes[c as usize];
749            if crate::layout_tree::is_context_menu(&node.component, &node.props) {
750                let id = node.id.clone();
751                let x = payload.as_ref().and_then(|p| p.get("x")).and_then(Value::as_f64);
752                let y = payload.as_ref().and_then(|p| p.get("y")).and_then(Value::as_f64);
753                match (x, y) {
754                    (Some(x), Some(y)) => {
755                        self.local.context_point.insert(id.clone(), (x as f32, y as f32));
756                    }
757                    _ => {
758                        self.local.context_point.remove(&id);
759                    }
760                }
761                return self.set_open(&id, true);
762            }
763            cur = node.parent;
764        }
765        vec![]
766    }
767
768    /// A menu entry pressed: a checkbox toggles (its `checked` binding
769    /// written), a submenu opens beside its row, an action selects + closes.
770    fn menu_select(&mut self, n: &LNode, owner_id: &str) -> Vec<OutEvent> {
771        let kind = n.props.get("kind").and_then(Value::as_str).unwrap_or("item").to_string();
772        let path: Vec<usize> = n.id.strip_prefix(&format!("{owner_id}.item.")).map(|s| s.split('.').filter_map(|p| p.parse().ok()).collect()).unwrap_or_default();
773        let mut out = vec![];
774        if kind == "submenu" {
775            let i = path.first().copied().unwrap_or(0);
776            if self.local.submenu.get(owner_id) == Some(&i) {
777                self.local.submenu.remove(owner_id);
778            } else {
779                self.local.submenu.insert(owner_id.to_string(), i);
780            }
781            self.needs_build = true;
782            out.push(OutEvent::Relayout);
783            return out;
784        }
785        let value = n.props.get("value").cloned().unwrap_or(Value::Null);
786        if kind == "checkbox" {
787            let checked = !n.props.get("checked").and_then(Value::as_bool).unwrap_or(false);
788            // The item's `checked` binding inside the source `items`.
789            if let Some(source) = self.source_node(owner_id) {
790                let mut item = source.props.get("items");
791                for (depth, i) in path.iter().enumerate() {
792                    let at = item.and_then(|a| a.get(*i));
793                    item = if depth + 1 < path.len() { at.and_then(|x| x.get("items")) } else { at };
794                }
795                if let Some(binding) = item.and_then(|i| i.get("checked")).filter(|b| is_binding(b)) {
796                    let p = absolute_path(binding["path"].as_str().unwrap_or(""), &self.scope_of(owner_id));
797                    if set_pointer(&mut self.data, &p, Some(Value::Bool(checked))).is_ok() {
798                        self.data_version += 1;
799                        self.needs_build = true;
800                        out.push(OutEvent::DataChanged { path: p, value: Value::Bool(checked) });
801                    }
802                }
803            }
804            out.extend(self.fire(owner_id, "select", Some(json!({"value": value, "checked": checked}))));
805            // A checkbox entry toggles and the menu STAYS open (a11y.json,
806            // the React renderer); an action closes it.
807        } else {
808            out.extend(self.fire(owner_id, "select", Some(json!({"value": value}))));
809            out.extend(self.set_open(owner_id, false));
810        }
811        out
812    }
813
814    /// EXP-1259: a search field's text changed: the filter reruns, the
815    /// highlight goes back to the top row, the list to its start. `key` =
816    /// the SearchList's id or a searchable submenu's `search` part id.
817    fn search_query(&mut self, key: &str, owner_id: &str, list_id: &str, query: String) -> Vec<OutEvent> {
818        let _ = owner_id;
819        self.local.query.insert(key.to_string(), query);
820        self.local.active.remove(key);
821        self.local.scroll.remove(list_id);
822        self.needs_build = true;
823        vec![OutEvent::Relayout]
824    }
825
826    /// The live rows of a search list (`item` parts under `list_id`), in
827    /// order: (slot, position).
828    fn search_rows(&self, list_id: &str) -> Vec<u32> {
829        let Some(&list) = self.slot_of.get(list_id) else { return vec![] };
830        self.nodes[list as usize].children.iter().copied().filter(|&c| self.live.get(c as usize).copied().unwrap_or(false) && self.nodes[c as usize].part.as_deref() == Some("item")).collect()
831    }
832
833    /// A key in a search field: ArrowDown / ArrowUp move the highlighted
834    /// row (no wrap) and scroll it into the list's window; Enter / Tab
835    /// pick it.
836    fn search_key(&mut self, key: &str, owner_id: &str, list_id: &str, pressed: &str) -> Vec<OutEvent> {
837        let rows = self.search_rows(list_id);
838        if rows.is_empty() {
839            return vec![];
840        }
841        let active = self.local.active.get(key).copied().unwrap_or(0).min(rows.len() - 1);
842        match pressed {
843            "ArrowDown" | "ArrowUp" => {
844                let next = if pressed == "ArrowDown" { (active + 1).min(rows.len() - 1) } else { active.saturating_sub(1) };
845                if next == active {
846                    return vec![];
847                }
848                self.local.active.insert(key.to_string(), next);
849                // Keep the row inside the window (the frames of the last pass).
850                let slot = rows[next];
851                if let (Some(&list), Some(row)) = (self.slot_of.get(list_id), self.last_frames.get(slot as usize).copied()) {
852                    if let Some(view) = self.last_frames.get(list as usize).copied() {
853                        let offset = self.scroll_offset(list_id).1;
854                        let reveal = crate::layout_tree::search::reveal_offset(next, row.h, view.h, offset);
855                        if (reveal - offset).abs() > 0.5 {
856                            self.local.scroll.insert(list_id.to_string(), (0.0, reveal));
857                        }
858                    }
859                }
860                self.needs_build = true;
861                vec![OutEvent::Relayout]
862            }
863            "Enter" | "Tab" => {
864                let n = self.nodes[rows[active] as usize].clone();
865                if n.props.get("disabled").and_then(Value::as_bool) == Some(true) {
866                    return vec![];
867                }
868                if self.nodes[rows[active] as usize].owner_component.as_deref() == Some("Menu") {
869                    self.menu_select(&n, owner_id)
870                } else {
871                    self.search_pick(&n, owner_id)
872                }
873            }
874            _ => vec![],
875        }
876    }
877
878    /// A SearchList row picked: `value` written (bound, else held locally),
879    /// `select` fired.
880    fn search_pick(&mut self, n: &LNode, owner_id: &str) -> Vec<OutEvent> {
881        let value = n.props.get("value").cloned().unwrap_or(Value::Null);
882        let mut out = self.write_field(owner_id, "value", value.clone());
883        out.extend(self.fire(owner_id, "select", Some(json!({"value": value}))));
884        out.push(OutEvent::Relayout);
885        out
886    }
887
888    fn select_option(&mut self, n: &LNode, owner_id: &str) -> Vec<OutEvent> {
889        let v = n.props.get("value").map(js).unwrap_or_default();
890        let owner = self.node_by_id(owner_id).cloned();
891        let multiple = owner.as_ref().and_then(|o| o.props.get("multiple")).and_then(Value::as_bool).unwrap_or(false);
892        let value = if multiple {
893            let current = self.current_prop(owner_id, "value").filter(|v| !v.is_null()).map(|v| js(&v)).unwrap_or_default();
894            let mut set: Vec<String> = current.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect();
895            if set.contains(&v) {
896                set.retain(|x| *x != v);
897            } else {
898                set.push(v.clone());
899            }
900            set.join(",")
901        } else {
902            // VAPP-146: picking the option that holds the current value ("" for
903            // an unseeded one) only closes: nothing is written or fired.
904            if self.current_prop(owner_id, "value").filter(|v| !v.is_null()).map(|v| js(&v)).unwrap_or_default() == v {
905                return self.set_open(owner_id, false);
906            }
907            v
908        };
909        self.local.errors.remove(owner_id);
910        let mut out = self.write_field(owner_id, "value", Value::String(value.clone()));
911        out.extend(self.fire(owner_id, "change", Some(json!({"value": value}))));
912        if multiple {
913            out.push(OutEvent::Relayout);
914        } else {
915            out.extend(self.set_open(owner_id, false));
916        }
917        out
918    }
919
920    fn calendar_month(&self, id: &str) -> (i32, u32) {
921        if let Some(m) = self.local.month.get(id) {
922            return *m;
923        }
924        let owner = self.node_by_id(id);
925        let start = owner.and_then(|o| o.props.get("value").or_else(|| o.props.get("start"))).and_then(Value::as_str).and_then(dates::parse);
926        // The builder's order: the value, `today` (the prop, else the host's), `min`.
927        let prop = |k: &str| owner.and_then(|o| o.props.get(k)).and_then(Value::as_str).and_then(dates::parse).map(|(y, m, _)| (y, m));
928        start
929            .map(|(y, m, _)| (y, m))
930            .or_else(|| prop("today"))
931            .or_else(|| self.settings.today.as_deref().and_then(dates::parse).map(|(y, m, _)| (y, m)))
932            .or_else(|| prop("min"))
933            .unwrap_or((2026, 1))
934    }
935
936    /// DateRangePicker: the first pick anchors, the second completes the
937    /// range (ordered) and closes.
938    fn range_pick(&mut self, n: &LNode, owner_id: &str) -> Vec<OutEvent> {
939        let date = n.props.get("date").map(js).unwrap_or_default();
940        let mut out = vec![];
941        match self.local.range_anchor.remove(owner_id) {
942            None => {
943                self.local.range_anchor.insert(owner_id.to_string(), date);
944                self.needs_build = true;
945                out.push(OutEvent::Relayout);
946            }
947            Some(first) => {
948                let (start, end) = if first <= date { (first, date) } else { (date, first) };
949                let a = self.write_through(owner_id, "start", Value::String(start.clone()));
950                let b = self.write_through(owner_id, "end", Value::String(end.clone()));
951                if a.is_empty() && b.is_empty() {
952                    self.local.field_values.insert(owner_id.to_string(), json!({"start": start, "end": end}));
953                }
954                out.extend(a);
955                out.extend(b);
956                out.extend(self.fire(owner_id, "change", Some(json!({"start": start, "end": end}))));
957                out.extend(self.set_open(owner_id, false));
958            }
959        }
960        out
961    }
962
963    /// The reference's `clamp`: into `[min, max]`, then rounded to the
964    /// precision (`precision`, else the step's decimals) like `toFixed`.
965    fn clamp_number(&self, id: &str, v: f64) -> f64 {
966        let Some(owner) = self.node_by_id(id) else { return v };
967        let (_, precision) = number_step_precision(&owner.props);
968        clamp_number_value(v, number_prop(&owner.props, "min"), number_prop(&owner.props, "max"), precision)
969    }
970
971    /// A stepper press: from the value, else `min` when it is above 0, else
972    /// 0 (the reference's `stepBy`).
973    fn number_step(&mut self, id: &str, up: bool) -> Vec<OutEvent> {
974        let owner = self.node_by_id(id).cloned();
975        let (step, _) = owner.as_ref().map(|o| number_step_precision(&o.props)).unwrap_or((1.0, 0));
976        let min = owner.as_ref().and_then(|o| number_prop(&o.props, "min"));
977        let value = self.current_prop(id, "value").filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| match v {
978            Value::Number(n) => n.as_f64().unwrap_or(0.0),
979            other => crate::json::to_number(&other),
980        });
981        let current = value.unwrap_or_else(|| min.filter(|m| *m > 0.0).unwrap_or(0.0));
982        let next = self.clamp_number(id, if up { current + step } else { current - step });
983        let v = crate::json::number(next);
984        self.local.errors.remove(id);
985        let mut out = self.write_field(id, "value", v.clone());
986        out.extend(self.fire(id, "change", Some(json!({"value": v}))));
987        out.push(OutEvent::Relayout);
988        out
989    }
990
991    /// A prop's CURRENT value: the data model when bound, else the local
992    /// state an event left, else the node's props (events may arrive
993    /// several times between two layouts).
994    fn current_prop(&self, id: &str, prop: &str) -> Option<Value> {
995        if let Some(path) = self.binding_path(id, prop) {
996            let scope = self.scope_of(id);
997            return self.resolve_ctx(&scope).read(&path).cloned();
998        }
999        let local = match prop {
1000            "values" => self.local.chips.get(id).map(|v| Value::Array(v.iter().cloned().map(Value::String).collect())),
1001            "files" => self.local.files.get(id).map(|f| Value::Array(f.clone())),
1002            "selected" => self.local.selected.get(id).map(|v| Value::Array(v.iter().cloned().map(Value::String).collect())),
1003            "sort" => self.local.sort.get(id).map(|(k, d)| json!({"key": k, "direction": d})),
1004            "checked" | "value" => self.local.field_values.get(id).cloned(),
1005            _ => None,
1006        };
1007        local.or_else(|| self.node_by_id(id).and_then(|o| o.props.get(prop)).cloned())
1008    }
1009
1010    fn chip_values(&self, id: &str) -> Vec<String> {
1011        self.current_prop(id, "values").and_then(|v| v.as_array().map(|a| a.iter().map(js).collect())).unwrap_or_default()
1012    }
1013
1014    fn chips_write(&mut self, id: &str, values: Vec<String>) -> Vec<OutEvent> {
1015        let value = Value::Array(values.iter().cloned().map(Value::String).collect());
1016        let out = self.write_through(id, "values", value);
1017        if out.is_empty() {
1018            self.local.chips.insert(id.to_string(), values);
1019        } else {
1020            self.local.chips.remove(id);
1021        }
1022        self.needs_build = true;
1023        out
1024    }
1025
1026    fn chip_add(&mut self, id: &str, value: &str) -> Vec<OutEvent> {
1027        self.local.query.remove(id);
1028        self.needs_build = true;
1029        let mut values = self.chip_values(id);
1030        let max = self.node_by_id(id).and_then(|o| o.props.get("max")).and_then(Value::as_f64);
1031        if value.is_empty() || values.iter().any(|v| v == value) || max.is_some_and(|m| values.len() as f64 >= m) {
1032            return vec![OutEvent::Relayout];
1033        }
1034        values.push(value.to_string());
1035        let mut out = self.chips_write(id, values.clone());
1036        out.extend(self.fire(id, "add", Some(json!({"value": value}))));
1037        out.extend(self.fire(id, "change", Some(json!({"values": values}))));
1038        out.push(OutEvent::Relayout);
1039        out
1040    }
1041
1042    fn chip_remove(&mut self, id: &str, value: &str) -> Vec<OutEvent> {
1043        let mut values = self.chip_values(id);
1044        values.retain(|v| v != value);
1045        let mut out = self.chips_write(id, values.clone());
1046        out.extend(self.fire(id, "remove", Some(json!({"value": value}))));
1047        out.extend(self.fire(id, "change", Some(json!({"values": values}))));
1048        out.push(OutEvent::Relayout);
1049        out
1050    }
1051
1052    fn files_of(&self, id: &str) -> Vec<Value> {
1053        self.current_prop(id, "files").and_then(|v| v.as_array().cloned()).unwrap_or_default()
1054    }
1055
1056    fn files_write(&mut self, id: &str, files: Vec<Value>) -> Vec<OutEvent> {
1057        let out = self.write_through(id, "files", Value::Array(files.clone()));
1058        if out.is_empty() {
1059            self.local.files.insert(id.to_string(), files);
1060        } else {
1061            self.local.files.remove(id);
1062        }
1063        self.needs_build = true;
1064        out
1065    }
1066
1067    /// Picked or dropped files (`{files: [{name, size, type}]}`): too-large
1068    /// ones are refused (the field shows `fileTooLargeNamed`), the rest attached
1069    /// and announced to the host as `upload`.
1070    fn files_upload(&mut self, id: &str, payload: Option<Value>) -> Vec<OutEvent> {
1071        let incoming: Vec<Value> = payload.as_ref().and_then(|p| p.get("files")).and_then(Value::as_array).cloned().unwrap_or_default();
1072        let owner = self.node_by_id(id).cloned();
1073        let max = owner.as_ref().and_then(|o| o.props.get("maxSize")).and_then(Value::as_f64);
1074        let multiple = owner.as_ref().and_then(|o| o.props.get("multiple")).and_then(Value::as_bool).unwrap_or(false);
1075        let (ok, refused): (Vec<Value>, Vec<Value>) = incoming.into_iter().partition(|f| !max.is_some_and(|m| f.get("size").and_then(Value::as_f64).is_some_and(|s| s > m)));
1076        if !refused.is_empty() {
1077            let name = owner.as_ref().and_then(|o| o.props.get("name")).map(js).unwrap_or_default();
1078            let template = self.strings.get("fileTooLargeNamed").cloned().unwrap_or_default();
1079            let messages = refused
1080                .iter()
1081                .map(|f| {
1082                    let mut params = serde_json::Map::new();
1083                    params.insert("name".into(), Value::String(f.get("name").map(js).unwrap_or_default()));
1084                    crate::strings::format_string(&template, &params)
1085                })
1086                .collect();
1087            self.local.errors.insert(id.to_string(), FieldErrors { name, messages });
1088        } else {
1089            self.local.errors.remove(id);
1090        }
1091        let accepted: Vec<Value> = if multiple { ok } else { ok.into_iter().take(1).collect() };
1092        if accepted.is_empty() {
1093            // Nothing attached: the list stays (the refusals show).
1094            self.needs_build = true;
1095            return vec![OutEvent::Relayout];
1096        }
1097        // A re-picked name replaces its earlier entry (the React reference).
1098        let mut files: Vec<Value> = if multiple { self.files_of(id) } else { Vec::new() };
1099        files.retain(|f| !accepted.iter().any(|a| a.get("name") == f.get("name")));
1100        files.extend(accepted.iter().cloned());
1101        // The own write = the whole list; the payload = the whole list plus
1102        // the new batch (`added`).
1103        let mut out = self.files_write(id, files.clone());
1104        out.extend(self.fire(id, "upload", Some(json!({"files": files, "added": accepted}))));
1105        out.push(OutEvent::Relayout);
1106        out
1107    }
1108
1109    fn file_remove(&mut self, id: &str, i: usize) -> Vec<OutEvent> {
1110        let mut files = self.files_of(id);
1111        if i >= files.len() {
1112            return vec![];
1113        }
1114        let removed = files.remove(i);
1115        let mut out = self.files_write(id, files.clone());
1116        out.extend(self.fire(id, "remove", Some(json!({"name": removed.get("name").cloned().unwrap_or(Value::Null), "files": files}))));
1117        out.push(OutEvent::Relayout);
1118        out
1119    }
1120
1121    fn table_sort(&mut self, n: &LNode, id: &str) -> Vec<OutEvent> {
1122        if n.props.get("sortable").and_then(Value::as_bool) != Some(true) {
1123            return vec![];
1124        }
1125        let key = n.props.get("key").map(js).unwrap_or_default();
1126        let current = self.current_prop(id, "sort");
1127        let same = current.as_ref().and_then(|s| s.get("key")).map(js).as_deref() == Some(key.as_str());
1128        let dir = if same && current.as_ref().and_then(|s| s.get("direction")).and_then(Value::as_str) == Some("asc") { "desc" } else { "asc" };
1129        let sort = json!({"key": key, "direction": dir});
1130        let mut out = self.write_through(id, "sort", sort.clone());
1131        if out.is_empty() {
1132            self.local.sort.insert(id.to_string(), (key, dir.to_string()));
1133        }
1134        self.needs_build = true;
1135        out.extend(self.fire(id, "sort", Some(json!({"sort": sort}))));
1136        out.push(OutEvent::Relayout);
1137        out
1138    }
1139
1140    fn table_selected(&self, id: &str) -> Vec<String> {
1141        self.current_prop(id, "selected").and_then(|v| v.as_array().map(|a| a.iter().map(js).collect())).unwrap_or_default()
1142    }
1143
1144    fn table_select(&mut self, id: &str, selected: Vec<String>) -> Vec<OutEvent> {
1145        let value = Value::Array(selected.iter().cloned().map(Value::String).collect());
1146        let mut out = self.write_through(id, "selected", value.clone());
1147        if out.is_empty() {
1148            self.local.selected.insert(id.to_string(), selected);
1149        }
1150        self.needs_build = true;
1151        out.extend(self.fire(id, "select", Some(json!({"selected": value}))));
1152        out.push(OutEvent::Relayout);
1153        out
1154    }
1155
1156    fn table_row(&mut self, n: &LNode, id: &str) -> Vec<OutEvent> {
1157        let key = n.props.get("key").map(js).unwrap_or_default();
1158        let row = self.node_by_id(id).and_then(|o| o.props.get("rows")).and_then(|r| r.get(n.props.get("index").and_then(Value::as_u64).unwrap_or(0) as usize)).cloned().unwrap_or(Value::Null);
1159        let mut out = self.fire(id, "rowPress", Some(json!({"key": key, "row": row})));
1160        let selectable = self.node_by_id(id).and_then(|o| o.props.get("selectable")).and_then(Value::as_str).unwrap_or("none").to_string();
1161        if selectable == "single" {
1162            let current = self.table_selected(id);
1163            let next = if current == [key.clone()] { vec![] } else { vec![key] };
1164            out.extend(self.table_select(id, next));
1165        }
1166        out
1167    }
1168
1169    fn table_check(&mut self, n: &LNode, id: &str) -> Vec<OutEvent> {
1170        let mut selected = self.table_selected(id);
1171        if n.props.get("header").and_then(Value::as_bool) == Some(true) {
1172            let all: Vec<String> = self.nodes.iter().enumerate().filter(|(i, r)| self.live[*i] && r.owner.as_deref() == Some(id) && r.part.as_deref() == Some("row")).filter_map(|(_, r)| r.props.get("key").map(js)).collect();
1173            let rows = self.node_by_id(id).and_then(|o| o.props.get("rows")).and_then(Value::as_array).map(Vec::len).unwrap_or(0);
1174            let checked = n.props.get("checked").and_then(Value::as_bool).unwrap_or(false);
1175            selected = if checked {
1176                vec![]
1177            } else if all.len() == rows {
1178                all
1179            } else {
1180                let key = self.node_by_id(id).and_then(|o| o.props.get("rowKey")).map(js).unwrap_or_else(|| "id".into());
1181                self.node_by_id(id).and_then(|o| o.props.get("rows")).and_then(Value::as_array).map(|rs| rs.iter().enumerate().map(|(i, r)| r.get(&key).map(js).unwrap_or_else(|| i.to_string())).collect()).unwrap_or_default()
1182            };
1183        } else {
1184            let key = n.props.get("key").map(js).unwrap_or_default();
1185            if selected.contains(&key) {
1186                selected.retain(|k| *k != key);
1187            } else {
1188                selected.push(key);
1189            }
1190        }
1191        self.table_select(id, selected)
1192    }
1193
1194    // ------------------------------------------------------------------
1195    // Resizable (round 2 §1)
1196    // ------------------------------------------------------------------
1197
1198    /// A Resizable's sizes as shown, its limits, axis, rtl and slot.
1199    fn resizable_state(&self, owner_id: &str) -> Option<ResizableState> {
1200        let slot = *self.slot_of.get(owner_id)?;
1201        let owner = &self.nodes[slot as usize];
1202        let sizes: Vec<f64> = owner.props.get("sizes").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect())?;
1203        let limits = crate::resizable::PanelLimits::list(owner.props.get("panels"));
1204        let orientation = if owner.props.get("direction").and_then(Value::as_str) == Some("vertical") { crate::resizable::Orientation::Vertical } else { crate::resizable::Orientation::Horizontal };
1205        let rtl = self.node_state.get(slot as usize).is_some_and(|s| s.direction == taffy::style::Direction::Rtl);
1206        Some((sizes, limits, orientation, rtl, slot))
1207    }
1208
1209    /// A handle drag or key. `drag {phase: start|move|end, delta}` (`delta`
1210    /// = px on the main axis since the drag STARTED, screen direction):
1211    /// sizes follow from the START sizes (no drift), the end writes a bound
1212    /// `sizes` and fires `change {sizes}`. `key {key}` (Arrow*/Home/End/
1213    /// Enter, `keyboardResize`) writes and fires at once. Like the
1214    /// reference, nothing is written or fired when the sizes did not change
1215    /// (a click without movement, a key at a limit, any other key).
1216    fn resize_event(&mut self, n: &LNode, owner_id: &str, event: &str, payload: Option<&Value>) -> Vec<OutEvent> {
1217        let Some((sizes, limits, orientation, rtl, slot)) = self.resizable_state(owner_id) else { return vec![] };
1218        let handle = n.props.get("handle").and_then(Value::as_u64).unwrap_or(0) as usize;
1219        let hairline = self.effective.as_ref().and_then(|t| t.tokens.control.get("hairline").copied()).unwrap_or(1.0);
1220        let next = if event == "key" {
1221            let key = payload.and_then(|p| p.get("key")).and_then(Value::as_str).unwrap_or("");
1222            if !crate::resizable::RESIZE_KEYS.contains(&key) {
1223                return vec![];
1224            }
1225            let next = crate::resizable::keyboard_resize(&sizes, handle, key, orientation, rtl, &limits);
1226            if next == sizes {
1227                return vec![];
1228            }
1229            next
1230        } else {
1231            let phase = payload.and_then(|p| p.get("phase")).and_then(Value::as_str).unwrap_or("move");
1232            if phase == "start" {
1233                self.local.drags.insert(n.id.clone(), sizes);
1234                return vec![];
1235            }
1236            let start = self.local.drags.get(&n.id).cloned().unwrap_or(sizes.clone());
1237            let px = payload.and_then(|p| p.get("delta")).and_then(Value::as_f64).unwrap_or(0.0);
1238            let frame = self.last_frames.get(slot as usize).copied().unwrap_or_default();
1239            let container = if orientation == crate::resizable::Orientation::Vertical { frame.h } else { frame.w } as f64;
1240            let delta = crate::resizable::drag_delta(px, container, start.len(), orientation, rtl, hairline);
1241            let next = crate::resizable::resize_panels(&start, handle, delta, &limits);
1242            if phase != "end" {
1243                if next != sizes {
1244                    self.local.sizes.insert(owner_id.to_string(), next);
1245                    self.subtree_pending.insert(owner_id.to_string());
1246                    return vec![OutEvent::Relayout];
1247                }
1248                return vec![];
1249            }
1250            self.local.drags.remove(&n.id);
1251            if next == start {
1252                // No net movement: drop the live preview, commit nothing.
1253                if sizes != start {
1254                    if self.binding_path(owner_id, "sizes").is_some() {
1255                        self.local.sizes.remove(owner_id);
1256                    } else {
1257                        self.local.sizes.insert(owner_id.to_string(), start);
1258                    }
1259                    self.subtree_pending.insert(owner_id.to_string());
1260                    return vec![OutEvent::Relayout];
1261                }
1262                return vec![];
1263            }
1264            next
1265        };
1266        let value = json!(next);
1267        let mut out = self.write_through(owner_id, "sizes", value.clone());
1268        if out.is_empty() {
1269            self.local.sizes.insert(owner_id.to_string(), next);
1270        } else {
1271            self.local.sizes.remove(owner_id);
1272        }
1273        self.needs_build = true;
1274        out.extend(self.fire(owner_id, "change", Some(json!({"sizes": value}))));
1275        out.push(OutEvent::Relayout);
1276        out
1277    }
1278
1279    // ------------------------------------------------------------------
1280    // Forms
1281    // ------------------------------------------------------------------
1282
1283    /// Validation `checks` of a field evaluated against the data model: the
1284    /// messages of the failing ones. The reference's `failingChecks`: a check
1285    /// WITH a `condition` fails when it resolves to nothing (an unseeded
1286    /// path), `null`, `false` or `""` (0 passes); a `$string.<id>` message
1287    /// resolves through the surface's strings; a message that is not a string
1288    /// reads `$string.invalidValue` (round 2).
1289    pub fn failing_checks(&self, id: &str) -> Vec<String> {
1290        let Some(node) = self.source_node(id) else { return vec![] };
1291        let scope = self.scope_of(id);
1292        let ctx = self.resolve_ctx(&scope);
1293        node.props
1294            .get("checks")
1295            .and_then(Value::as_array)
1296            .map(|checks| {
1297                checks
1298                    .iter()
1299                    .filter(|c| {
1300                        let Some(cond) = c.get("condition") else { return false };
1301                        check_fails(crate::data::resolve_value(cond, &ctx).as_ref())
1302                    })
1303                    .map(|c| c.get("message").and_then(Value::as_str).map(|message| crate::strings::resolve_string(message, &self.strings).to_string()).unwrap_or_else(|| self.strings.get("invalidValue").cloned().unwrap_or_else(|| "Invalid value".into())))
1304                    .collect()
1305            })
1306            .unwrap_or_default()
1307    }
1308
1309    /// A field's `validateOn` (its prop, else the catalog default, else blur).
1310    fn validate_on(&self, id: &str) -> String {
1311        let node = self.node_by_id(id);
1312        node.and_then(|n| n.props.get("validateOn"))
1313            .and_then(Value::as_str)
1314            .map(str::to_string)
1315            .or_else(|| node.and_then(|n| self.view.component(&n.component)).and_then(|d| d.props.get("validateOn")).and_then(|p| p.default.as_ref()).and_then(Value::as_str).map(str::to_string))
1316            .unwrap_or_else(|| "blur".into())
1317    }
1318
1319    /// Run one field's checks now (its errors show or clear).
1320    fn validate_field(&mut self, id: &str) {
1321        let failed = self.failing_checks(id);
1322        let name = self.node_by_id(id).and_then(|n| n.props.get("name")).map(js).unwrap_or_default();
1323        let before = self.local.errors.get(id).map(|e| e.messages.clone());
1324        if failed.is_empty() {
1325            self.local.errors.remove(id);
1326        } else {
1327            self.local.errors.insert(id.to_string(), FieldErrors { name, messages: failed.clone() });
1328        }
1329        if before.unwrap_or_default() != failed {
1330            self.needs_build = true;
1331        }
1332    }
1333
1334    /// The value a field contributes to its Form's `submit`.
1335    fn field_value(&self, id: &str, component: &str) -> Value {
1336        if let Some(v) = self.local.field_values.get(id) {
1337            return v.clone();
1338        }
1339        let props = self.node_by_id(id).map(|n| n.props.clone()).unwrap_or_default();
1340        match component {
1341            "Checkbox" | "Switch" => props.get("checked").cloned().unwrap_or(Value::Bool(false)),
1342            "ChipInput" => props.get("values").cloned().unwrap_or(json!([])),
1343            "FileUpload" => props.get("files").cloned().unwrap_or(json!([])),
1344            "DateRangePicker" => json!({"start": props.get("start").cloned().unwrap_or(Value::Null), "end": props.get("end").cloned().unwrap_or(Value::Null)}),
1345            "TimePicker" => props.get("value").filter(|v| v.as_str().and_then(parse_time).is_some()).cloned().unwrap_or(Value::Null),
1346            _ => props.get("value").cloned().unwrap_or(Value::Null),
1347        }
1348    }
1349
1350    /// Submit a Form: every field's checks run; any failure → no `submit`,
1351    /// `invalid {errors}`, every failing field shows its messages, focus
1352    /// moves to the first and `invalidFields` is announced. A busy form
1353    /// refuses.
1354    pub fn submit_form(&mut self, form_id: &str) -> Vec<OutEvent> {
1355        self.ensure_reduced();
1356        let Some(form) = self.source_node(form_id) else { return vec![] };
1357        if self.node_by_id(form_id).and_then(|n| n.props.get("busy")).and_then(Value::as_bool) == Some(true) {
1358            return vec![];
1359        }
1360        let mut fields: Vec<(String, String, String)> = Vec::new();
1361        fn walk(n: &UiNode, out: &mut Vec<(String, String, String)>, top: bool) {
1362            if !top && n.component == "Form" {
1363                return;
1364            }
1365            if FORM_FIELDS.contains(&n.component.as_str()) {
1366                if let Some(name) = n.props.get("name").and_then(Value::as_str) {
1367                    out.push((n.id.clone(), n.component.clone(), name.to_string()));
1368                }
1369            }
1370            for s in n.slots.iter().flat_map(|s| s.values()) {
1371                walk(s, out, false);
1372            }
1373            for c in &n.children {
1374                walk(c, out, false);
1375            }
1376        }
1377        walk(&form, &mut fields, true);
1378        // A Form inside a template row / slot cell: its fields are instances
1379        // with the same suffix as the Form.
1380        let suffix = self.instance_suffix(form_id);
1381        if !suffix.is_empty() {
1382            for f in &mut fields {
1383                f.0.push_str(&suffix);
1384            }
1385        }
1386        let mut errors = Vec::new();
1387        let mut first_invalid: Option<String> = None;
1388        for (id, _, name) in &fields {
1389            let failed = self.failing_checks(id);
1390            if failed.is_empty() {
1391                self.local.errors.remove(id);
1392                continue;
1393            }
1394            first_invalid.get_or_insert_with(|| id.clone());
1395            for m in &failed {
1396                errors.push(json!({"name": name, "message": m}));
1397            }
1398            self.local.errors.insert(id.clone(), FieldErrors { name: name.clone(), messages: failed });
1399        }
1400        self.needs_build = true;
1401        let mut out = vec![];
1402        if let Some(first) = first_invalid {
1403            out.extend(self.fire(form_id, "invalid", Some(json!({"errors": errors}))));
1404            let index = self.slot_of.get(&first).copied().unwrap_or(0);
1405            out.push(OutEvent::Focus { id: first, index });
1406            out.push(OutEvent::Announce { text: self.strings.get("invalidFields").cloned().unwrap_or_default(), live: "assertive".into() });
1407            out.push(OutEvent::Relayout);
1408            return out;
1409        }
1410        let mut values = Map::new();
1411        for (id, component, name) in &fields {
1412            values.insert(name.clone(), self.field_value(id, component));
1413        }
1414        out.extend(self.fire(form_id, "submit", Some(json!({"values": values}))));
1415        // Round 4 (VAPP-103, `src/dynamic.ts submitClosesOverlay`): a valid
1416        // submit closes the nearest overlay around the form when it is a
1417        // dismissible Dialog or Drawer, like a dismiss (`open` false
1418        // written through, `change {open: false}`).
1419        if let Some(overlay) = self.enclosing_modal(form_id) {
1420            out.extend(self.set_open(&overlay, false));
1421        }
1422        out.push(OutEvent::Relayout);
1423        out
1424    }
1425
1426    /// The overlay a valid submit of form `id` closes (`src/dynamic.ts
1427    /// submitClosesOverlay`): the NEAREST enclosing overlay of any kind
1428    /// decides — up the parent chain, a layer's root continuing at the
1429    /// overlay that owns it — and closes only when it is a dismissible
1430    /// Dialog or Drawer (`dismissible: false`, an AlertDialog, never
1431    /// auto-closes); a nearest Popover, Menu, Tooltip or Toast closes
1432    /// nothing.
1433    fn enclosing_modal(&self, id: &str) -> Option<String> {
1434        const OVERLAYS: [&str; 6] = ["Dialog", "Drawer", "Popover", "Tooltip", "Menu", "Toast"];
1435        let mut cur = self.node_by_id(id).map(|n| n.index);
1436        let mut guard = 0;
1437        let nearest = loop {
1438            let Some(i) = cur else { return None };
1439            guard += 1;
1440            if guard > 100_000 {
1441                return None;
1442            }
1443            let n = &self.nodes[i as usize];
1444            if n.part.is_none() && OVERLAYS.contains(&n.component.as_str()) && n.id != id {
1445                break n.id.clone();
1446            }
1447            if n.owner_component.as_deref().is_some_and(|c| OVERLAYS.contains(&c)) {
1448                break n.owner.clone()?;
1449            }
1450            cur = n.parent.or_else(|| n.owner.as_deref().and_then(|o| self.node_by_id(o)).map(|o| o.index));
1451        };
1452        let overlay = self.node_by_id(&nearest)?;
1453        let modal = matches!(overlay.component.as_str(), "Dialog" | "Drawer");
1454        let dismissible = overlay.props.get("dismissible").and_then(Value::as_bool) != Some(false);
1455        (modal && dismissible).then_some(nearest)
1456    }
1457
1458    // ------------------------------------------------------------------
1459    // Host commands
1460    // ------------------------------------------------------------------
1461
1462    /// `focus {id}`, `announce {text, live}`, `scrollIntoView {id}`.
1463    pub fn command(&mut self, command: &SurfaceCommand) -> Vec<OutEvent> {
1464        self.ensure_reduced();
1465        match command {
1466            SurfaceCommand::Announce { text, live } => vec![OutEvent::Announce { text: text.clone(), live: live.clone().unwrap_or_else(|| "polite".into()) }],
1467            SurfaceCommand::Focus { id } => {
1468                let Some(&slot) = self.slot_of.get(id) else { return vec![] };
1469                // The first focusable node at or below it.
1470                let mut stack = vec![slot];
1471                while let Some(s) = stack.pop() {
1472                    let n = &self.nodes[s as usize];
1473                    if n.hidden {
1474                        continue;
1475                    }
1476                    if n.pressable || matches!(n.part.as_deref(), Some("field" | "input" | "trigger" | "search")) {
1477                        return vec![OutEvent::Focus { id: n.id.clone(), index: s }];
1478                    }
1479                    stack.extend(n.children.iter().rev());
1480                }
1481                vec![OutEvent::Focus { id: id.clone(), index: slot }]
1482            }
1483            SurfaceCommand::ScrollIntoView { id } => self.scroll_into_view(id),
1484            SurfaceCommand::ScrollToIndex { id, index, align } => self.scroll_to_index(id, *index as usize, align.as_deref().and_then(crate::list::ScrollAlign::parse).unwrap_or_default()),
1485        }
1486    }
1487
1488    /// Round 2 (§5): bring item `index` (DATA order, before a local sort)
1489    /// of the List or Table `id` into view, `align`ed, the pinned section
1490    /// header subtracted. Its own, its scrolling ancestor's or the host's
1491    /// offset moves (the host's: [`OutEvent::ScrollSurface`]); a windowed
1492    /// list renders the item on the next pass.
1493    pub fn scroll_to_index(&mut self, id: &str, index: usize, align: crate::list::ScrollAlign) -> Vec<OutEvent> {
1494        self.ensure_reduced();
1495        use crate::layout_tree::ListViewSource;
1496        let Some(spec) = self.lists.iter().find(|l| l.id == id).cloned() else { return vec![] };
1497        let pos = match &spec.data_index {
1498            Some(map) => map.iter().position(|d| *d == Some(index)),
1499            None => Some(spec.static_count + index),
1500        };
1501        let Some(pos) = pos.filter(|p| *p < spec.keys.len()) else { return vec![] };
1502        // Over the window's cached offsets: a row's extent = the next
1503        // offset − the gap (O(1) past the cache).
1504        let Some(window) = self.local.lists.get_mut(id) else { return vec![] };
1505        window.gap = spec.gap;
1506        let offsets = window.offsets_cached(&spec.keys);
1507        let count = spec.keys.len();
1508        let extent = |r: usize| (offsets[r + 1] - offsets[r] - if r + 1 < count { spec.gap } else { 0.0 }) as f64;
1509        let h = spec.horizontal;
1510        let axis = |p: (f32, f32)| if h { p.0 } else { p.1 };
1511        let own = self.local.scroll.get(id).copied().map(axis).unwrap_or(0.0);
1512        let view = self.local.list_views.get(id).cloned();
1513        let (source, viewport) = match view {
1514            Some(v) => (v.source, v.viewport),
1515            None => (ListViewSource::Own, if h { self.viewport.0 } else if self.viewport.1 > 0.0 { self.viewport.1 } else { self.max_height.unwrap_or(2000.0) }),
1516        };
1517        let scroll = match &source {
1518            ListViewSource::Own => own,
1519            ListViewSource::Ancestor { id: a, rel } => self.local.scroll.get(a).copied().map(axis).unwrap_or(0.0) - rel,
1520            ListViewSource::Host { rel } => self.local.surface_scroll.map(|p| axis(p) - rel).unwrap_or(own),
1521        };
1522        // The pinned header over the item: its section's header extent.
1523        let inset = if spec.sticky {
1524            let before = spec.headers.partition_point(|&hp| hp < pos);
1525            before.checked_sub(1).map(|i| extent(spec.headers[i])).unwrap_or(0.0)
1526        } else {
1527            0.0
1528        };
1529        let target = crate::list::scroll_offset_at(offsets[pos] as f64, extent(pos), offsets[count] as f64, viewport as f64, scroll.max(0.0) as f64, align, inset) as f32;
1530        let with_axis = |p: (f32, f32), v: f32| if h { (v, p.1) } else { (p.0, v) };
1531        let mut out = vec![];
1532        match source {
1533            ListViewSource::Own => {
1534                let (x, y) = with_axis(self.local.scroll.get(id).copied().unwrap_or_default(), target);
1535                self.local.scroll.insert(id.to_string(), (x, y));
1536            }
1537            ListViewSource::Ancestor { id: a, rel } => {
1538                let (x, y) = with_axis(self.local.scroll.get(&a).copied().unwrap_or_default(), target + rel);
1539                self.local.scroll.insert(a.clone(), (x, y));
1540            }
1541            ListViewSource::Host { rel } => match self.local.surface_scroll {
1542                Some(p) => {
1543                    let (x, y) = with_axis(p, target + rel);
1544                    self.local.surface_scroll = Some((x, y));
1545                    out.push(OutEvent::ScrollSurface { x, y });
1546                }
1547                None => {
1548                    let (x, y) = with_axis(self.local.scroll.get(id).copied().unwrap_or_default(), target);
1549                    self.local.scroll.insert(id.to_string(), (x, y));
1550                }
1551            },
1552        }
1553        if spec.windowed {
1554            self.subtree_pending.insert(id.to_string());
1555        }
1556        out.push(OutEvent::Relayout);
1557        out
1558    }
1559
1560    /// Scroll every scroll container above `id` so the node is in view (a
1561    /// windowed row that is not rendered scrolls by its key's offset).
1562    pub fn scroll_into_view(&mut self, id: &str) -> Vec<OutEvent> {
1563        self.ensure_reduced();
1564        let mut out = vec![];
1565        if let Some(&slot) = self.slot_of.get(id) {
1566            let target = self.last_frames.get(slot as usize).copied().unwrap_or_default();
1567            let mut cur = self.nodes[slot as usize].parent;
1568            while let Some(p) = cur {
1569                if let Some(s) = self.scrolls.iter().find(|s| s.index == p).copied() {
1570                    let frame = self.last_frames[p as usize];
1571                    let pid = self.scroll_key(p);
1572                    let (mut ox, mut oy) = (s.offset_x, s.offset_y);
1573                    let top = target.y - frame.y;
1574                    let left = target.x - frame.x;
1575                    if s.scroll_y {
1576                        if top < oy {
1577                            oy = top;
1578                        } else if top + target.h > oy + frame.h {
1579                            oy = top + target.h - frame.h;
1580                        }
1581                    }
1582                    if s.scroll_x {
1583                        if left < ox {
1584                            ox = left;
1585                        } else if left + target.w > ox + frame.w {
1586                            ox = left + target.w - frame.w;
1587                        }
1588                    }
1589                    if self.scroll_to(&pid, ox, oy) {
1590                        out.push(OutEvent::Relayout);
1591                    }
1592                }
1593                cur = self.nodes[p as usize].parent;
1594            }
1595            return out;
1596        }
1597        // A windowed row outside the window: scroll its list to it.
1598        for l in self.lists.clone() {
1599            if let Some(pos) = l.keys.iter().position(|k| k == id || id.ends_with(&format!(".{k}"))) {
1600                let y = l.offsets.get(pos).copied().unwrap_or(0.0);
1601                if self.scroll_to(&l.id, 0.0, y) {
1602                    out.push(OutEvent::Relayout);
1603                }
1604            }
1605        }
1606        out
1607    }
1608}
1609
1610/// What a Resizable event reads: sizes, limits, orientation, rtl, slot.
1611type ResizableState = (Vec<f64>, Vec<crate::resizable::PanelLimits>, crate::resizable::Orientation, bool, u32);