Skip to main content

eui_core/layout_tree/
fields.rs

1//! The form controls (a themed surface expands each into its recipe parts):
2//! Input/Textarea, Select (the combobox, its popup a LAYER), DatePicker and
3//! DateRangePicker (one calendar layer), TimePicker (a list layer),
4//! NumberField (steppers), ChipInput (chips + suggestions layer), FileUpload
5//! (drop zone + files), Checkbox/Switch, Radio, Slider and the Form that
6//! collects them. A field's failed checks (after a refused submit) show
7//! under it as `<field id>.error` and put its parts in the `invalid` state.
8
9use serde_json::{json, Map, Value};
10
11use super::{bool_prop, js, str_prop, Builder, LNode, LayerPlacement, NodeKind};
12use crate::overlay::{OverlayAlign, OverlaySide};
13use crate::theme::RecipeQuery;
14use crate::types::UiNode;
15
16/// Calendar arithmetic for the pickers (proleptic Gregorian, no clock).
17pub mod dates {
18    pub fn is_leap(y: i32) -> bool {
19        (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
20    }
21
22    pub fn days_in_month(y: i32, m: u32) -> u32 {
23        match m {
24            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
25            4 | 6 | 9 | 11 => 30,
26            _ => {
27                if is_leap(y) {
28                    29
29                } else {
30                    28
31                }
32            }
33        }
34    }
35
36    /// 0 = Sunday … 6 = Saturday (Sakamoto).
37    pub fn weekday(y: i32, m: u32, d: u32) -> u32 {
38        const T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
39        let y = if m < 3 { y - 1 } else { y };
40        ((y + y / 4 - y / 100 + y / 400 + T[(m - 1) as usize] + d as i32).rem_euclid(7)) as u32
41    }
42
43    /// `yyyy-mm-dd` → (y, m, d).
44    pub fn parse(s: &str) -> Option<(i32, u32, u32)> {
45        let b = s.get(0..10)?;
46        let mut it = b.split('-');
47        let y = it.next()?.parse().ok()?;
48        let m: u32 = it.next()?.parse().ok()?;
49        let d: u32 = it.next()?.parse().ok()?;
50        ((1..=12).contains(&m) && d >= 1 && d <= days_in_month(y, m)).then_some((y, m, d))
51    }
52
53    pub fn format(y: i32, m: u32, d: u32) -> String {
54        format!("{y:04}-{m:02}-{d:02}")
55    }
56
57    /// The month after/before.
58    pub fn shift_month(y: i32, m: u32, delta: i32) -> (i32, u32) {
59        let total = y * 12 + (m as i32 - 1) + delta;
60        (total.div_euclid(12), (total.rem_euclid(12) + 1) as u32)
61    }
62
63    /// The day `n` days after (y, m, d).
64    pub fn add_days(y: i32, m: u32, d: u32, n: i32) -> (i32, u32, u32) {
65        let (mut y, mut m, mut d) = (y, m, d as i32 + n);
66        while d < 1 {
67            let (py, pm) = shift_month(y, m, -1);
68            y = py;
69            m = pm;
70            d += days_in_month(y, m) as i32;
71        }
72        while d > days_in_month(y, m) as i32 {
73            d -= days_in_month(y, m) as i32;
74            let (ny, nm) = shift_month(y, m, 1);
75            y = ny;
76            m = nm;
77        }
78        (y, m, d as u32)
79    }
80
81    pub const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
82    pub const WEEKDAYS_SHORT: [&str; 7] = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
83
84    /// `Oct 7, 2026` (the `en` medium date; natives format in the surface
85    /// locale from the ISO value).
86    pub fn label(s: &str) -> String {
87        match parse(s) {
88            Some((y, m, d)) => format!("{} {d}, {y}", &MONTHS[(m - 1) as usize][..3]),
89            None => s.to_string(),
90        }
91    }
92}
93
94/// #VAPP-282: a DatePicker's `marks` → the tone of each day's dot: the
95/// FIRST mark of a day wins, no tone = `primary` (fixtures/month-grid.json).
96pub fn mark_tones(marks: Option<&Value>) -> std::collections::HashMap<String, String> {
97    let mut out = std::collections::HashMap::new();
98    for m in marks.and_then(Value::as_array).into_iter().flatten() {
99        let Some(date) = m.get("date").and_then(Value::as_str).and_then(|d| d.get(0..10)) else { continue };
100        if dates::parse(date).is_none() || out.contains_key(date) {
101            continue;
102        }
103        let tone = m.get("tone").and_then(Value::as_str).filter(|t| !t.is_empty()).unwrap_or("primary");
104        out.insert(date.to_string(), tone.to_string());
105    }
106    out
107}
108
109/// `HH:mm` → minutes since midnight.
110pub fn parse_time(s: &str) -> Option<u32> {
111    let (h, m) = s.split_once(':')?;
112    let h: u32 = h.parse().ok()?;
113    let m: u32 = m.get(0..2)?.parse().ok()?;
114    (h < 24 && m < 60).then_some(h * 60 + m)
115}
116
117pub fn format_time(minutes: u32) -> String {
118    format!("{:02}:{:02}", minutes / 60, minutes % 60)
119}
120
121/// Round a plain non-negative decimal string (`123.4567`, no exponent) to
122/// `p` fraction digits, ties AWAY from zero (a next digit ≥ 5 rounds up).
123fn round_decimal(s: &str, p: usize) -> String {
124    let (int, frac) = s.split_once('.').unwrap_or((s, ""));
125    let mut digits: Vec<u8> = int.bytes().chain(frac.bytes().chain(std::iter::repeat(b'0')).take(p)).collect();
126    if frac.as_bytes().get(p).copied().unwrap_or(b'0') >= b'5' {
127        let mut k = digits.len();
128        loop {
129            if k == 0 {
130                digits.insert(0, b'1');
131                break;
132            }
133            k -= 1;
134            if digits[k] == b'9' {
135                digits[k] = b'0';
136            } else {
137                digits[k] += 1;
138                break;
139            }
140        }
141    }
142    let split = digits.len() - p;
143    let mut out = String::from_utf8_lossy(&digits[..split]).into_owned();
144    if out.is_empty() {
145        out.push('0');
146    }
147    if p > 0 {
148        out.push('.');
149        out.push_str(&String::from_utf8_lossy(&digits[split..]));
150    }
151    out
152}
153
154/// JavaScript's `String(n)` ([`crate::json::number_to_string`]).
155pub fn js_number_string(n: f64) -> String {
156    crate::json::number_to_string(n)
157}
158
159/// The reference's `decimalsOf(step)`: the digits after the `.` of
160/// `String(step)`.
161pub fn decimals_of(n: f64) -> u32 {
162    let s = js_number_string(n);
163    s.find('.').map(|i| (s.len() - i - 1) as u32).unwrap_or(0)
164}
165
166/// JavaScript's `n.toFixed(p)`: the EXACT binary value rounded half away
167/// from zero (`(2.5).toFixed(0)` = `3`, `(1.005).toFixed(2)` = `1.00`).
168pub fn to_fixed(n: f64, p: u32) -> String {
169    if !n.is_finite() || n.abs() >= 1e21 {
170        return js_number_string(n);
171    }
172    let p = p.min(100) as usize;
173    // Every f64 has at most 1074 fraction digits: this is the exact value.
174    let exact = format!("{:.1100}", n.abs());
175    let body = round_decimal(&exact, p);
176    // `(-0.04).toFixed(1)` = "-0.0"; `(-0).toFixed(1)` = "0.0".
177    if n < 0.0 {
178        format!("-{body}")
179    } else {
180        body
181    }
182}
183
184/// A NumberField's value as it shows: `Intl.NumberFormat("en", {minimum +
185/// maximumFractionDigits: precision})`: the SHORTEST round-trip decimal
186/// rounded half away from zero (`1.005` → `1.01`, `2.5` → `3`), grouped.
187pub fn format_number_value(n: f64, precision: u32) -> String {
188    if !n.is_finite() {
189        return js_number_string(n);
190    }
191    let shortest = format!("{}", n.abs());
192    let rounded = round_decimal(&shortest, precision.min(100) as usize);
193    let (int, frac) = rounded.split_once('.').unwrap_or((&rounded, ""));
194    let mut out = String::new();
195    if n.is_sign_negative() {
196        out.push('-');
197    }
198    let len = int.len();
199    for (i, c) in int.chars().enumerate() {
200        if i > 0 && (len - i) % 3 == 0 {
201            out.push(',');
202        }
203        out.push(c);
204    }
205    if !frac.is_empty() {
206        out.push('.');
207        out.push_str(frac);
208    }
209    out
210}
211
212/// A NumberField's step (`step || 1`) and precision (`precision`, else the
213/// step's decimals), as the reference reads them.
214pub fn number_step_precision(props: &crate::types::Props) -> (f64, u32) {
215    let step = props.get("step").map(crate::json::to_number).filter(|s| *s != 0.0 && s.is_finite()).unwrap_or(1.0);
216    let precision = match props.get("precision") {
217        Some(p) => crate::json::to_number(p).max(0.0).floor().min(100.0) as u32,
218        None => decimals_of(step),
219    };
220    (step, precision)
221}
222
223/// A numeric NumberField prop the reference's way (`num`): numbers and
224/// numeric strings; absent, null or `""` = none.
225pub fn number_prop(props: &crate::types::Props, key: &str) -> Option<f64> {
226    match props.get(key)? {
227        Value::Number(n) => n.as_f64(),
228        Value::String(s) if !s.trim().is_empty() => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).or(Some(0.0)),
229        Value::Null => None,
230        Value::String(_) => None,
231        _ => Some(0.0),
232    }
233}
234
235/// The reference's `clamp`: into `[min, max]`, then `Number(v.toFixed(p))`.
236pub fn clamp_number_value(v: f64, min: Option<f64>, max: Option<f64>, precision: u32) -> f64 {
237    let mut v = v;
238    if let Some(m) = min {
239        v = v.max(m);
240    }
241    if let Some(m) = max {
242        v = v.min(m);
243    }
244    to_fixed(v, precision).parse().unwrap_or(v)
245}
246
247/// A byte count as the web shows it (`Intl` `style: "unit"`, short): 1024
248/// steps, at most one fraction digit, through [`Formatter::bytes`]
249/// (`47.1 kB` in the English fallback).
250///
251/// [`Formatter::bytes`]: crate::format::Formatter::bytes
252pub fn format_bytes_with(formatter: &dyn crate::format::Formatter, n: f64) -> String {
253    use crate::format::ByteUnit;
254    const UNITS: [ByteUnit; 4] = [ByteUnit::Byte, ByteUnit::Kilobyte, ByteUnit::Megabyte, ByteUnit::Gigabyte];
255    let mut v = n.max(0.0);
256    let mut i = 0;
257    while v >= 1024.0 && i < UNITS.len() - 1 {
258        v /= 1024.0;
259        i += 1;
260    }
261    formatter.bytes((v * 10.0).round() / 10.0, UNITS[i])
262}
263
264/// [`format_bytes_with`] through the English formatter.
265pub fn format_bytes(n: f64) -> String {
266    format_bytes_with(&crate::format::EnglishFormatter, n)
267}
268
269impl Builder<'_, '_> {
270    /// The column frame every labelled field shares: label first.
271    fn field_open(&mut self, index: u32) -> LNode {
272        self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column")), ("gap", json!("$spacing.xs"))]);
273        self.nodes[index as usize].kind = NodeKind::Container;
274        let owner = self.nodes[index as usize].clone();
275        if let Some(label) = str_prop(&owner.props, "label") {
276            self.text_part(index, &owner, "label", label, "label", "");
277        }
278        owner
279    }
280
281    /// The failed checks of a field (after a refused submit) plus an
282    /// authored `error`.
283    fn field_errors(&self, owner: &LNode) -> Vec<String> {
284        let mut out: Vec<String> = self.ctx.local.errors.get(&owner.id).map(|e| e.messages.clone()).unwrap_or_default();
285        if let Some(e) = str_prop(&owner.props, "error") {
286            out.push(e.to_string());
287        }
288        out
289    }
290
291    /// Description and error lines under the control; `control` (the part
292    /// that IS the control) gets the `invalid` state.
293    fn field_close(&mut self, owner: &LNode, control: Option<u32>) {
294        if let Some(d) = str_prop(&owner.props, "description") {
295            self.text_part(owner.index, owner, "description", d, "muted", "");
296        }
297        let errors = self.field_errors(owner);
298        if !errors.is_empty() {
299            let e = self.text_part(owner.index, owner, "error", &errors.join("\n"), "caption", "");
300            self.add_state(e, "invalid");
301            if let Some(c) = control {
302                self.add_state(c, "invalid");
303            }
304        }
305    }
306
307    /// Props for a field's control part: the owner's minus the frame's.
308    fn control_props(owner: &LNode) -> Value {
309        let mut props = owner.props.clone();
310        for k in ["label", "description", "style", "checks", "error"] {
311            props.shift_remove(k);
312        }
313        Value::Object(props)
314    }
315
316    /// Input / Textarea: label, the host-measured field, description, error.
317    pub(crate) fn field(&mut self, index: u32) {
318        let owner = self.field_open(index);
319        let f = self.part(&owner, "field", &owner.component, NodeKind::Leaf, json!({"alignSelf": "stretch"}), Self::control_props(&owner));
320        self.nodes[f as usize].pressable = !bool_prop(&owner.props, "disabled");
321        self.field_close(&owner, Some(f));
322    }
323
324    /// A trigger part that opens the owner's popup layer.
325    fn trigger(&mut self, owner: &LNode, text: &str, placeholder: bool, glyph: &str) -> u32 {
326        let mut props = Self::control_props(owner);
327        props["text"] = json!(text);
328        props["placeholder"] = json!(placeholder);
329        props["icon"] = json!(super::builtin_icon(glyph).unwrap_or("ui-selector"));
330        let t = self.part(owner, "trigger", &owner.component, NodeKind::Leaf, json!({"alignSelf": "stretch"}), props);
331        let disabled = bool_prop(&owner.props, "disabled");
332        self.nodes[t as usize].pressable = !disabled;
333        self.nodes[t as usize].trigger_for = Some(owner.id.clone());
334        if self.is_open(&owner.id, &Map::new(), false) {
335            self.add_state(t, "open");
336        }
337        t
338    }
339
340    /// The anchored popup of a field: its layer root (`part`), placed under
341    /// the trigger, at least as wide.
342    fn popup(&mut self, owner: &LNode, part: &str, anchor: u32, style: Value) -> (u32, u32) {
343        let (layer, root) = self.layer_root(owner, part, style);
344        let _ = anchor;
345        (layer, root)
346    }
347
348    /// Select = the combobox: trigger; open → a layer with an optional
349    /// search field, the (filtered) options and `empty`.
350    pub(crate) fn select(&mut self, index: u32) {
351        let owner = self.field_open(index);
352        // VAPP-103: only OBJECT options paint (`options: [null]` skips the entry).
353        let options: Vec<Value> = owner.props.get("options").and_then(Value::as_array).map(|a| a.iter().filter(|o| o.is_object()).cloned().collect()).unwrap_or_default();
354        let multiple = bool_prop(&owner.props, "multiple");
355        let value = owner.props.get("value").map(js).unwrap_or_default();
356        let label_of = |v: &str| options.iter().find(|o| o.get("value").map(js).as_deref() == Some(v)).and_then(|o| o.get("label")).map(js);
357        // VAPP-146: "" is an ordinary single value: an option holding it (an
358        // unseeded value reads as "") is the one chosen.
359        let chosen: Vec<String> = if multiple { value.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect() } else { vec![value.clone()].into_iter().filter(|v| !v.is_empty() || label_of(v).is_some()).collect() };
360        let shown: Vec<String> = chosen.iter().map(|v| label_of(v).unwrap_or_else(|| v.clone())).collect();
361        let placeholder = str_prop(&owner.props, "placeholder").map(str::to_string).unwrap_or_else(|| self.string("choose"));
362        let (text, is_placeholder) = if shown.is_empty() { (placeholder, true) } else { (shown.join(", "), false) };
363        let trigger = self.trigger(&owner, &text, is_placeholder, "Select.trigger");
364        self.field_close(&owner, Some(trigger));
365        if !self.is_open(&owner.id, &Map::new(), false) {
366            return;
367        }
368        let (layer, root) = self.popup(&owner, "content", trigger, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.xxs", "padding": "$spacing.xs", "borderRadius": "$radius.md", "maxHeight": 320}));
369        let query = self.ctx.local.query.get(&owner.id).cloned().unwrap_or_default();
370        if bool_prop(&owner.props, "searchable") {
371            let search = self.part_in(root, &owner, "search", "Select", NodeKind::Leaf, json!({"alignSelf": "stretch", "flexShrink": 0}), json!({"placeholder": self.string("search"), "value": query, "icon": super::builtin_icon("Select.search")}), "");
372            self.nodes[search as usize].pressable = true;
373        }
374        let list = self.part_in(root, &owner, "list", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "flexShrink": 1, "minHeight": 0, "overflowY": "scroll"}), json!({}), "");
375        self.nodes[list as usize].part_query = None;
376        let needle = query.to_lowercase();
377        let mut shown_any = false;
378        for (i, option) in options.iter().enumerate() {
379            let label = option.get("label").map(js).unwrap_or_default();
380            if !needle.is_empty() && !label.to_lowercase().contains(&needle) {
381                continue;
382            }
383            shown_any = true;
384            let v = option.get("value").map(js).unwrap_or_default();
385            let selected = chosen.contains(&v);
386            let disabled = option.get("disabled").and_then(Value::as_bool).unwrap_or(false);
387            let mut props = json!({"text": label, "value": v, "lines": 1, "selected": selected, "disabled": disabled});
388            if let Some(icon) = option.get("icon") {
389                props["icon"] = icon.clone();
390            }
391            if selected {
392                props["check"] = json!(super::builtin_icon("Select.check"));
393            }
394            let item = self.part_in(list, &owner, "item", "Text", NodeKind::Leaf, json!({"display": "flex", "flexDirection": "row", "alignItems": "center"}), props, &format!(".{i}"));
395            self.nodes[item as usize].pressable = !disabled;
396            if selected {
397                self.add_state(item, "selected");
398            }
399            if disabled {
400                self.add_state(item, "disabled");
401            }
402        }
403        if !shown_any {
404            let empty = owner.props.get("emptyText").map(js).unwrap_or_else(|| self.string("noResults"));
405            self.text_part(list, &owner, "empty", &empty, "muted", "");
406        }
407        self.push_layer(layer, root, &owner, LayerPlacement::Anchored { anchor: trigger, side: OverlaySide::Bottom, align: OverlayAlign::Start }, false, true, true, Some(list));
408    }
409
410    /// A `yyyy-mm-dd` as the surface formatter's medium date (the raw
411    /// value when unreadable).
412    pub(crate) fn date_label(&self, iso: &str) -> String {
413        match crate::format::format_date_value(self.ctx.formatter, &json!(iso), &crate::format::DateOptions::default()) {
414            s if s.is_empty() => iso.to_string(),
415            s => s,
416        }
417    }
418
419    /// A `yyyy-mm-dd` through a TR35 pattern of the surface formatter.
420    pub(crate) fn date_pattern(&self, iso: &str, pattern: &str) -> String {
421        crate::format::format_date_value(self.ctx.formatter, &json!(iso), &crate::format::DateOptions { format: Some(pattern.to_string()), ..Default::default() })
422    }
423
424    /// DatePicker / DateRangePicker: trigger; open → one calendar layer:
425    /// header (previous, the month title, next), the weekday row, six weeks
426    /// of `day` cells from the week start (`firstDayOfWeek`, else the
427    /// locale's). #VAPP-282: a DatePicker `inline: true` builds the same
428    /// `calendar` IN FLOW (no trigger, no layer, start-aligned); `today`
429    /// (else the host's) rings a day (recipe prop `isToday`), outside days
430    /// carry `outside`, `marks` put a `mark` tone on their days
431    /// (fixtures/month-grid.json).
432    pub(crate) fn date_picker(&mut self, index: u32) {
433        let owner = self.field_open(index);
434        let range = owner.component == "DateRangePicker";
435        let inline = !range && bool_prop(&owner.props, "inline");
436        let (start, end) = if range {
437            (owner.props.get("start").and_then(Value::as_str).unwrap_or("").to_string(), owner.props.get("end").and_then(Value::as_str).unwrap_or("").to_string())
438        } else {
439            (owner.props.get("value").and_then(Value::as_str).unwrap_or("").to_string(), String::new())
440        };
441        let placeholder = str_prop(&owner.props, "placeholder").map(str::to_string).unwrap_or_else(|| self.string("choose"));
442        let text = match (range, start.is_empty()) {
443            (_, true) => placeholder.clone(),
444            (false, false) => self.date_label(&start),
445            (true, false) => format!("{} – {}", self.date_label(&start), if end.is_empty() { "…".to_string() } else { self.date_label(&end) }),
446        };
447        let glyph = format!("{}.trigger", owner.component);
448        let trigger = if inline { None } else { Some(self.trigger(&owner, &text, start.is_empty(), &glyph)) };
449        if !inline {
450            self.field_close(&owner, trigger);
451        }
452        if !inline && !self.is_open(&owner.id, &Map::new(), false) {
453            return;
454        }
455        let today = owner.props.get("today").and_then(Value::as_str).filter(|t| dates::parse(t).is_some()).map(str::to_string).or_else(|| self.today());
456        let marks = mark_tones(owner.props.get("marks"));
457        let (y, m) = self.ctx.local.month.get(&owner.id).copied().or_else(|| dates::parse(&start).map(|(y, m, _)| (y, m))).or_else(|| today.as_deref().and_then(dates::parse).map(|(y, m, _)| (y, m))).or_else(|| owner.props.get("min").and_then(Value::as_str).and_then(dates::parse).map(|(y, m, _)| (y, m))).unwrap_or((2026, 1));
458        let first_dow = owner.props.get("firstDayOfWeek").and_then(Value::as_u64).map(|d| (d % 7) as u32).unwrap_or_else(|| crate::locale::week_start(self.ctx.locale) as u32);
459        // #VAPP-282: the web reference's geometry (web/base-css.ts
460        // `.xui-calendar-*`): header 28 tall (28 x 28 steppers), `sm` to the
461        // grid; the grid = the weekday row (24 tall, narrow names) and six
462        // weeks, `xxs` apart; a day = a 32-wide column, its label centred.
463        let calendar_style = json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.sm", "padding": "$spacing.sm", "borderRadius": "$radius.md"});
464        let (layer, root) = match trigger {
465            Some(t) => self.popup(&owner, "calendar", t, calendar_style),
466            None => {
467                let mut style = calendar_style;
468                style["alignSelf"] = json!("flex-start");
469                (0, self.part(&owner, "calendar", "Box", NodeKind::Container, style, json!({})))
470            }
471        };
472        let header = self.part_in(root, &owner, "header", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "justifyContent": "space-between", "gap": "$spacing.xs"}), json!({}), "");
473        self.nodes[header as usize].part_query = None;
474        let button = |b: &mut Self, parent: u32, part: &str, glyph: &str, label_id: &str| {
475            let label = b.string(label_id);
476            let n = b.part_in(parent, &owner, part, "Button", NodeKind::Leaf, json!({"flexShrink": 0, "width": 28, "height": 28, "borderRadius": "$radius.sm"}), json!({"icon": super::builtin_icon(glyph), "label": label, "variant": "ghost", "size": "icon"}), "");
477            b.nodes[n as usize].pressable = true;
478            b.nodes[n as usize].own_query = Some(RecipeQuery::new("Button", "root", b.ctx.recipes.native_recipe_props("Button", &b.nodes[n as usize].props.clone()), Vec::new()));
479            n
480        };
481        button(self, header, "previous", &format!("{}.previousMonth", "DatePicker"), "previousMonth");
482        // Round 2: month and weekday names through the surface formatter.
483        let title = self.date_pattern(&dates::format(y, m, 1), "MMMM yyyy");
484        let t = self.part_in(header, &owner, "title", "Text", NodeKind::Leaf, json!({"flexGrow": 1, "textAlign": "center", "fontWeight": "$type.weight.medium"}), json!({"text": title, "variant": "label", "month": m, "year": y}), "");
485        self.nodes[t as usize].live = Some("polite".into());
486        button(self, header, "next", "DatePicker.nextMonth", "nextMonth");
487        let week_style = json!({"display": "flex", "flexDirection": "row", "gap": "$spacing.xxs"});
488        let day_h = self.ctx.menu_row;
489        let cell_style = json!({"flexGrow": 1, "flexBasis": 0, "minWidth": 32, "height": day_h, "lineHeight": day_h, "textAlign": "center"});
490        let weekday_style = json!({"flexGrow": 1, "flexBasis": 0, "minWidth": 32, "height": 24, "lineHeight": 24, "textAlign": "center", "fontSize": "$type.size.xs", "color": "$color.mutedForeground"});
491        let grid = self.part_in(root, &owner, "grid", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.xxs"}), json!({}), "");
492        self.nodes[grid as usize].part_query = None;
493        let weekdays = self.part_in(grid, &owner, "weekdays", "Box", NodeKind::Container, week_style.clone(), json!({}), "");
494        self.nodes[weekdays as usize].part_query = None;
495        for c in 0..7u32 {
496            let dow = (first_dow + c) % 7;
497            // 2026-01-04 is a Sunday: the column head = the narrow name
498            // (`EEEEE`), its accessible name the short one (`EEE`).
499            let iso = dates::format(2026, 1, 4 + dow);
500            let (name, short) = (self.date_pattern(&iso, "EEEEE"), self.date_pattern(&iso, "EEE"));
501            let w = self.part_in(weekdays, &owner, "weekday", "Text", NodeKind::Leaf, weekday_style.clone(), json!({"text": name, "variant": "caption", "weekday": dow}), &format!(".{c}"));
502            self.nodes[w as usize].part_query = None;
503            self.default_a11y(w, "label", json!(short));
504        }
505        let lead = (dates::weekday(y, m, 1) + 7 - first_dow) % 7;
506        let (sy, sm, sd) = dates::add_days(y, m, 1, -(lead as i32));
507        let min = owner.props.get("min").and_then(Value::as_str).unwrap_or("").to_string();
508        let max = owner.props.get("max").and_then(Value::as_str).unwrap_or("").to_string();
509        let anchor = self.ctx.local.range_anchor.get(&owner.id).cloned();
510        let (lo, hi) = match (&anchor, range) {
511            (Some(a), true) => (a.clone(), String::new()),
512            _ => (start.clone(), end.clone()),
513        };
514        for r in 0..6 {
515            let week = self.part_in(grid, &owner, "week", "Box", NodeKind::Container, week_style.clone(), json!({}), &format!(".{r}"));
516            self.nodes[week as usize].part_query = None;
517            for c in 0..7 {
518                let (dy, dm, dd) = dates::add_days(sy, sm, sd, r * 7 + c);
519                let date = dates::format(dy, dm, dd);
520                let outside = dm != m;
521                let disabled = (!min.is_empty() && date < min) || (!max.is_empty() && date > max);
522                let is_start = !lo.is_empty() && date == lo;
523                let is_end = range && !hi.is_empty() && date == hi;
524                let in_range = range && !lo.is_empty() && !hi.is_empty() && date > lo && date < hi;
525                let selected = is_start || is_end;
526                let is_today = today.as_deref() == Some(date.as_str());
527                let mut props = json!({"text": dd.to_string(), "date": date, "outside": outside, "disabled": disabled, "selected": selected, "inRange": in_range, "rangeStart": range && is_start, "rangeEnd": is_end, "today": is_today});
528                if let Some(tone) = marks.get(&date) {
529                    props["mark"] = json!(tone);
530                }
531                let day = self.part_in(week, &owner, "day", "Text", NodeKind::Leaf, cell_style.clone(), props, &format!(".{r}.{c}"));
532                // The cell's name = the full localized date (the reference's
533                // `{weekday: long, year, month: long, day}`).
534                let label = crate::format::format_date_value(self.ctx.formatter, &json!(date), &crate::format::DateOptions { style: Some(crate::format::DateStyle::Full), ..Default::default() });
535                self.default_a11y(day, "label", json!(label));
536                self.nodes[day as usize].pressable = !disabled;
537                if selected {
538                    self.add_state(day, "selected");
539                }
540                if disabled {
541                    self.add_state(day, "disabled");
542                }
543                if in_range {
544                    self.query_prop(day, "inRange", Value::Bool(true));
545                }
546                if is_today {
547                    self.query_prop(day, "isToday", Value::Bool(true));
548                }
549                if outside {
550                    self.query_prop(day, "outside", Value::Bool(true));
551                }
552            }
553        }
554        match trigger {
555            Some(anchor) => self.push_layer(layer, root, &owner, LayerPlacement::Anchored { anchor, side: OverlaySide::Bottom, align: OverlayAlign::Start }, false, true, false, None),
556            None => self.field_close(&owner, None),
557        }
558    }
559
560    /// The host's notion of today (`SurfaceSettings.today`), if set.
561    fn today(&self) -> Option<String> {
562        self.ctx.today.clone()
563    }
564
565    /// TimePicker: trigger; open → a scrolling list layer every `step`
566    /// minutes from `min` to `max`.
567    pub(crate) fn time_picker(&mut self, index: u32) {
568        let owner = self.field_open(index);
569        let value = owner.props.get("value").and_then(Value::as_str).unwrap_or("").to_string();
570        let placeholder = str_prop(&owner.props, "placeholder").map(str::to_string).unwrap_or_else(|| "--:--".to_string());
571        let trigger = self.trigger(&owner, if value.is_empty() { &placeholder } else { &value }, value.is_empty(), "TimePicker.trigger");
572        self.field_close(&owner, Some(trigger));
573        if !self.is_open(&owner.id, &Map::new(), false) {
574            return;
575        }
576        let step = owner.props.get("step").and_then(Value::as_f64).unwrap_or(15.0).max(1.0) as u32;
577        let min = owner.props.get("min").and_then(Value::as_str).and_then(parse_time).unwrap_or(0);
578        let max = owner.props.get("max").and_then(Value::as_str).and_then(parse_time).unwrap_or(23 * 60 + 59);
579        let (layer, root) = self.popup(&owner, "list", trigger, json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "maxHeight": 240, "overflowY": "scroll"}));
580        let mut t = min;
581        let mut i = 0;
582        while t <= max && i < 24 * 60 {
583            let label = format_time(t);
584            let selected = label == value;
585            let item = self.part_in(root, &owner, "item", "Text", NodeKind::Leaf, json!({}), json!({"text": label, "value": label, "lines": 1, "selected": selected}), &format!(".{i}"));
586            self.nodes[item as usize].pressable = true;
587            if selected {
588                self.add_state(item, "selected");
589            }
590            t += step;
591            i += 1;
592        }
593        self.push_layer(layer, root, &owner, LayerPlacement::Anchored { anchor: trigger, side: OverlaySide::Bottom, align: OverlayAlign::Start }, false, true, true, Some(root));
594    }
595
596    /// NumberField: the bordered `field` box holds decrement, the host text
597    /// `input`, the unit and increment.
598    pub(crate) fn number_field(&mut self, index: u32) {
599        let owner = self.field_open(index);
600        let value = number_prop(&owner.props, "value");
601        let min = number_prop(&owner.props, "min");
602        let max = number_prop(&owner.props, "max");
603        let (_, precision) = number_step_precision(&owner.props);
604        let disabled = bool_prop(&owner.props, "disabled");
605        let field = self.part(&owner, "field", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "alignSelf": "stretch", "gap": "$spacing.xxs"}), json!({}));
606        // The reference's `part('field', disabled && 'disabled')`: the whole
607        // frame dims (its recipe's `disabled` opacity), not only the value.
608        if disabled {
609            self.add_state(field, "disabled");
610        }
611        let stepper = |b: &mut Self, part: &str, at_limit: bool| {
612            let label = b.string(part);
613            let glyph = format!("NumberField.{part}");
614            let n = b.part_in(field, &owner, part, "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"icon": super::builtin_icon(&glyph), "label": label, "variant": "ghost", "size": "icon", "disabled": disabled || at_limit}), "");
615            b.nodes[n as usize].pressable = !(disabled || at_limit);
616            if disabled || at_limit {
617                b.add_state(n, "disabled");
618            }
619            n
620        };
621        stepper(self, "decrement", value.zip(min).is_some_and(|(v, m)| v <= m));
622        let text = value.map(|v| self.ctx.formatter.number(v, crate::format::NumberOptions { decimals: Some(precision), grouping: true })).unwrap_or_default();
623        let mut props = Self::control_props(&owner);
624        props["text"] = json!(text);
625        let input = self.part_in(field, &owner, "input", "NumberField", NodeKind::Leaf, json!({"flexGrow": 1, "flexBasis": 0, "minWidth": 40}), props, "");
626        self.nodes[input as usize].pressable = !disabled;
627        if let Some(unit) = str_prop(&owner.props, "unit") {
628            self.text_part(field, &owner, "unit", unit, "muted", "");
629        }
630        stepper(self, "increment", value.zip(max).is_some_and(|(v, m)| v >= m));
631        self.field_close(&owner, Some(field));
632    }
633
634    /// ChipInput: the `field` box wraps the chips (label + remove) and the
635    /// host text `input`; typed text offers matching suggestions in a layer.
636    pub(crate) fn chip_input(&mut self, index: u32) {
637        let owner = self.field_open(index);
638        let values: Vec<String> = owner.props.get("values").and_then(Value::as_array).map(|a| a.iter().map(js).collect()).unwrap_or_default();
639        let disabled = bool_prop(&owner.props, "disabled");
640        let field = self.part(&owner, "field", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "flexWrap": "wrap", "alignItems": "center", "alignSelf": "stretch", "gap": "$spacing.xs"}), json!({}));
641        if disabled {
642            self.add_state(field, "disabled");
643        }
644        for (i, v) in values.iter().enumerate() {
645            let suffix = format!(".{i}");
646            let chip = self.part_in(field, &owner, "chip", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.xxs", "flexShrink": 0}), json!({"value": v}), &suffix);
647            let _ = chip;
648            self.part_in(chip, &owner, "chipLabel", "Text", NodeKind::Leaf, json!({}), json!({"text": v, "lines": 1}), &suffix);
649            if !disabled {
650                let label = self.string_with("removeItem", &[("name", v)]);
651                let r = self.part_in(chip, &owner, "remove", "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": super::builtin_icon("ChipInput.remove"), "size": "sm", "label": label, "value": v}), &suffix);
652                self.nodes[r as usize].pressable = true;
653            }
654        }
655        let query = self.ctx.local.query.get(&owner.id).cloned().unwrap_or_default();
656        let at_max = owner.props.get("max").and_then(Value::as_f64).is_some_and(|m| values.len() as f64 >= m);
657        let placeholder = if values.is_empty() { str_prop(&owner.props, "placeholder").unwrap_or("").to_string() } else { String::new() };
658        let input = self.part_in(field, &owner, "input", "ChipInput", NodeKind::Leaf, json!({"flexGrow": 1, "flexBasis": 80, "minWidth": 80}), json!({"text": query, "placeholder": placeholder, "disabled": disabled || at_max}), "");
659        self.nodes[input as usize].pressable = !disabled;
660        self.field_close(&owner, Some(field));
661        let needle = query.trim().to_lowercase();
662        if needle.is_empty() || disabled || at_max {
663            return;
664        }
665        let suggestions: Vec<Value> = owner
666            .props
667            .get("suggestions")
668            .and_then(Value::as_array)
669            .map(|a| a.iter().filter(|o| o.get("label").map(js).is_some_and(|l| l.to_lowercase().contains(&needle)) && !values.contains(&o.get("value").map(js).unwrap_or_default())).cloned().collect())
670            .unwrap_or_default();
671        if suggestions.is_empty() {
672            return;
673        }
674        let (layer, root) = self.popup(&owner, "suggestions", field, json!({"display": "flex", "flexDirection": "column", "padding": "$spacing.xs", "borderRadius": "$radius.md", "maxHeight": 240, "overflowY": "scroll"}));
675        for (i, o) in suggestions.iter().enumerate() {
676            let item = self.part_in(root, &owner, "suggestion", "Text", NodeKind::Leaf, json!({}), json!({"text": o.get("label").map(js).unwrap_or_default(), "value": o.get("value").map(js).unwrap_or_default(), "lines": 1}), &format!(".{i}"));
677            self.nodes[item as usize].pressable = true;
678        }
679        self.push_layer(layer, root, &owner, LayerPlacement::Anchored { anchor: field, side: OverlaySide::Bottom, align: OverlayAlign::Start }, false, true, true, Some(root));
680    }
681
682    /// FileUpload: the dashed `dropzone` (icon, hint, browse) and one row
683    /// per attached file (name, size, remove).
684    pub(crate) fn file_upload(&mut self, index: u32) {
685        let owner = self.field_open(index);
686        let disabled = bool_prop(&owner.props, "disabled");
687        let zone = self.part(&owner, "dropzone", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "alignItems": "center", "gap": "$spacing.xs", "padding": "$spacing.md", "alignSelf": "stretch"}), json!({"accept": owner.props.get("accept").cloned().unwrap_or(Value::Null), "multiple": bool_prop(&owner.props, "multiple")}));
688        self.nodes[zone as usize].pressable = !disabled;
689        self.icon_part(zone, &owner, "icon", "FileUpload.icon", "");
690        let title = self.string("dropFiles");
691        let t = self.text_part(zone, &owner, "title", &title, "label", "");
692        // The title's weight (the web's `.xui-FileUpload-title`); a
693        // `FileUpload/title` recipe overrides it.
694        self.style_default(t, &[("fontWeight", json!("$type.weight.medium"))]);
695        if let Some(hint) = str_prop(&owner.props, "hint") {
696            self.text_part(zone, &owner, "hint", hint, "muted", "");
697        }
698        let browse_label = self.string("browse");
699        // EXP-1563: Browse carries no `disabled` of its own (inert through
700        // `pressable` alone): the disabled dropzone already dims it once,
701        // web's look; the Button's own disabled state on top drew it at a
702        // quarter on the natives.
703        let browse = self.part_in(zone, &owner, "browse", "Button", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"label": browse_label, "variant": "outline", "size": "sm"}), "");
704        self.nodes[browse as usize].pressable = !disabled;
705        self.nodes[browse as usize].own_query = Some(RecipeQuery::new("Button", "root", self.ctx.recipes.native_recipe_props("Button", &self.nodes[browse as usize].props.clone()), Vec::new()));
706        let files = owner.props.get("files").and_then(Value::as_array).cloned().unwrap_or_default();
707        for (i, f) in files.iter().enumerate() {
708            let suffix = format!(".{i}");
709            let name = f.get("name").map(js).unwrap_or_default();
710            let row = self.part_in(owner.index, &owner, "file", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.sm"}), json!({"name": name}), &suffix);
711            self.icon_part(row, &owner, "fileIcon", "FileUpload.file", &suffix);
712            self.part_in(row, &owner, "fileName", "Text", NodeKind::Leaf, json!({"flexGrow": 1, "flexShrink": 1, "minWidth": 0}), json!({"text": name, "lines": 1}), &suffix);
713            if let Some(size) = f.get("size").and_then(Value::as_f64) {
714                let meta = format_bytes_with(self.ctx.formatter, size);
715                self.text_part(row, &owner, "fileMeta", &meta, "muted", &suffix);
716            }
717            if !disabled {
718                let label = self.string_with("removeItem", &[("name", &name)]);
719                let r = self.part_in(row, &owner, "remove", "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": super::builtin_icon("FileUpload.remove"), "size": "sm", "label": label, "file": name}), &suffix);
720                self.nodes[r as usize].pressable = true;
721            }
722        }
723        self.field_close(&owner, Some(zone));
724    }
725
726    /// Checkbox / Switch: the control box beside label + description.
727    pub(crate) fn check(&mut self, index: u32) {
728        let owner = self.nodes[index as usize].clone();
729        self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("row")), ("alignItems", json!("center")), ("gap", json!("$spacing.sm"))]);
730        self.nodes[index as usize].kind = NodeKind::Container;
731        self.nodes[index as usize].pressable = !bool_prop(&owner.props, "disabled");
732        let control_part = if owner.component == "Switch" { "track" } else { "box" };
733        let checked = bool_prop(&owner.props, "checked");
734        let invalid = !self.field_errors(&owner).is_empty();
735        let mut control_props = json!({"checked": checked, "disabled": bool_prop(&owner.props, "disabled")});
736        if owner.component == "Switch" {
737            control_props["size"] = json!(str_prop(&owner.props, "size").unwrap_or("default"));
738        }
739        let control = |b: &mut Self, parent: u32| {
740            let c = b.part_in(parent, &owner, control_part, &owner.component, NodeKind::Leaf, json!({"flexShrink": 0}), control_props.clone(), "");
741            if checked {
742                b.add_state(c, "checked");
743            }
744            if invalid {
745                b.add_state(c, "invalid");
746            }
747            c
748        };
749        let text = |b: &mut Self, parent: u32| {
750            let has_desc = str_prop(&owner.props, "description").is_some();
751            if has_desc {
752                let col = b.part_in(parent, &owner, "body", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "gap": "$spacing.xxs", "flexGrow": 1, "minWidth": 0}), json!({}), "");
753                if let Some(label) = str_prop(&owner.props, "label") {
754                    b.text_part(col, &owner, "label", label, "body", "");
755                }
756                if let Some(d) = str_prop(&owner.props, "description") {
757                    b.text_part(col, &owner, "description", d, "muted", "");
758                }
759            } else if owner.component == "Switch" && str_prop(&owner.props, "label") == Some("") {
760                // VAPP-280: a label-less Switch (a SettingsRow's control,
761                // `label: ""`) keeps the web's empty `body` column, which
762                // grows and holds the track at the end; no empty label part.
763                b.part_in(parent, &owner, "body", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "flexGrow": 1, "minWidth": 0}), json!({}), "");
764            } else if let Some(label) = str_prop(&owner.props, "label") {
765                let l = b.text_part(parent, &owner, "label", label, "body", "");
766                b.nodes[l as usize].base_style.insert("flexGrow".into(), json!(1));
767                b.nodes[l as usize].base_style.insert("minWidth".into(), json!(0));
768            }
769        };
770        if owner.component == "Switch" {
771            text(self, index);
772            control(self, index);
773        } else {
774            control(self, index);
775            text(self, index);
776        }
777        let errors = self.field_errors(&owner);
778        if !errors.is_empty() {
779            let wrap_note = errors.join("\n");
780            let e = self.text_part(index, &owner, "error", &wrap_note, "caption", "");
781            self.nodes[e as usize].base_style.insert("flexBasis".into(), json!("100%"));
782            self.add_state(e, "invalid");
783            self.style_default(index, &[("flexWrap", json!("wrap"))]);
784        }
785    }
786
787    pub(crate) fn radio(&mut self, index: u32) {
788        let owner = self.nodes[index as usize].clone();
789        let horizontal = str_prop(&owner.props, "orientation") == Some("horizontal");
790        // The gap is the `Radio/root` recipe's ($spacing.sm, round 2 §7).
791        self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column"))]);
792        self.nodes[index as usize].kind = NodeKind::Container;
793        if let Some(label) = str_prop(&owner.props, "label") {
794            self.text_part(index, &owner, "label", label, "label", "");
795        }
796        let items = self.part(&owner, "items", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": if horizontal { "row" } else { "column" }, "gap": "$spacing.sm", "flexWrap": "wrap"}), json!({}));
797        let value = owner.props.get("value").map(js);
798        // VAPP-103: only OBJECT options paint (`options: [null]` skips the entry).
799        let options: Vec<Value> = owner.props.get("options").and_then(Value::as_array).map(|a| a.iter().filter(|o| o.is_object()).cloned().collect()).unwrap_or_default();
800        for (i, option) in options.iter().enumerate() {
801            let v = option.get("value").map(js).unwrap_or_default();
802            let checked = value.as_deref() == Some(v.as_str());
803            // VAPP-90: the ROW is a plain option row (no recipe — the
804            // `Radio/item` recipe is the 16 px circle); the `.dot` LEAF wears
805            // the `item` circle recipe and the painter draws `dot` inside it.
806            let row = self.part_in(items, &owner, "item", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.sm"}), json!({"value": v, "checked": checked}), &format!(".{i}"));
807            let disabled = bool_prop(&owner.props, "disabled") || option.get("disabled").and_then(Value::as_bool).unwrap_or(false);
808            self.nodes[row as usize].pressable = !disabled;
809            self.nodes[row as usize].part_query = None;
810            let dot = self.part_in(row, &owner, "dot", "Radio", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"checked": checked}), &format!(".{i}"));
811            self.nodes[dot as usize].pressable = !disabled;
812            let mut circle = RecipeQuery::new("Radio", "item", self.ctx.recipes.native_recipe_props("Radio", &owner.props), Vec::new());
813            if checked {
814                circle.states.push("checked".into());
815            }
816            self.nodes[dot as usize].part_query = Some(circle);
817            let label = option.get("label").map(js).unwrap_or_default();
818            self.text_part(row, &owner, "label", &label, "body", &format!(".{i}"));
819        }
820        let errors = self.field_errors(&owner);
821        if !errors.is_empty() {
822            let e = self.text_part(index, &owner, "error", &errors.join("\n"), "caption", "");
823            self.add_state(e, "invalid");
824        }
825    }
826
827    /// ChoiceTiles (EXP-1280): a grid of `columns` (1–4) equal tiles, one
828    /// per option: `tile.<i>` (pressable, `selected` when its value is in
829    /// `value`, comma-joined when `type: multiple`) holding `picture.<i>`
830    /// (an Image, `aspectRatio` 1.6, cover; EVERY tile has one once any
831    /// option has an `image`, so a row keeps one height: a missing picture
832    /// paints the Image fallback), `label.<i>` and `description.<i>`.
833    pub(crate) fn choice_tiles(&mut self, index: u32) {
834        let owner = self.nodes[index as usize].clone();
835        let columns = owner.props.get("columns").and_then(Value::as_f64).filter(|c| c.is_finite()).unwrap_or(2.0).round().clamp(1.0, 4.0) as u32;
836        // The gap is the `ChoiceTiles/root` recipe's.
837        self.style_default(index, &[("display", json!("grid")), ("gridTemplateColumns", json!(format!("repeat({columns}, minmax(0, 1fr))"))), ("alignItems", json!("stretch"))]);
838        self.nodes[index as usize].kind = NodeKind::Container;
839        let picked = choice_values(&owner.props);
840        let options: Vec<Value> = owner.props.get("options").and_then(Value::as_array).map(|a| a.iter().filter(|o| o.is_object()).cloned().collect()).unwrap_or_default();
841        let pictures = options.iter().any(|o| o.get("image").and_then(Value::as_str).is_some_and(|s| !s.is_empty()));
842        let all_disabled = bool_prop(&owner.props, "disabled");
843        let invalid = !self.field_errors(&owner).is_empty();
844        for (i, option) in options.iter().enumerate() {
845            let suffix = format!(".{i}");
846            let v = option.get("value").map(js).unwrap_or_default();
847            let selected = picked.contains(&v);
848            let disabled = all_disabled || option.get("disabled").and_then(Value::as_bool).unwrap_or(false);
849            let label = option.get("label").map(js).unwrap_or_default();
850            let tile = self.part_in(index, &owner, "tile", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "column", "minWidth": 0}), json!({"value": v, "label": label, "selected": selected, "disabled": disabled}), &suffix);
851            self.nodes[tile as usize].pressable = !disabled;
852            for (on, state) in [(selected, "selected"), (disabled, "disabled"), (invalid, "invalid")] {
853                if on {
854                    self.add_state(tile, state);
855                }
856            }
857            if pictures {
858                let src = option.get("image").and_then(Value::as_str).filter(|s| !s.is_empty());
859                let mut props = json!({"alt": "", "fit": "cover", "aspectRatio": CHOICE_PICTURE_ASPECT});
860                if let Some(src) = src {
861                    props["src"] = json!(src);
862                }
863                let pic = self.part_in(tile, &owner, "picture", "Image", NodeKind::Leaf, json!({"alignSelf": "stretch", "aspectRatio": CHOICE_PICTURE_ASPECT, "flexShrink": 0, "minWidth": 0}), props, &suffix);
864                let _ = pic;
865            }
866            self.text_part(tile, &owner, "label", &label, "body", &suffix);
867            if let Some(d) = option.get("description").map(js).filter(|d| !d.is_empty()) {
868                self.text_part(tile, &owner, "description", &d, "muted", &suffix);
869            }
870        }
871        let errors = self.field_errors(&owner);
872        if !errors.is_empty() {
873            let e = self.text_part(index, &owner, "error", &errors.join("\n"), "caption", "");
874            // The error spans every column.
875            self.nodes[e as usize].base_style.insert("gridColumn".into(), json!("1 / -1"));
876            self.add_state(e, "invalid");
877        }
878    }
879
880    pub(crate) fn slider(&mut self, index: u32) {
881        let owner = self.nodes[index as usize].clone();
882        self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column")), ("gap", json!("$spacing.xs"))]);
883        self.nodes[index as usize].kind = NodeKind::Container;
884        if owner.props.get("label").is_some() {
885            let head = self.part(&owner, "header", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "justifyContent": "space-between"}), json!({}));
886            if let Some(label) = str_prop(&owner.props, "label") {
887                self.text_part(head, &owner, "label", label, "label", "");
888            }
889            // #VAPP-286: `showValue: false` prints no value above the track.
890            let show_value = owner.props.get("showValue").and_then(Value::as_bool).unwrap_or(true);
891            if let Some(v) = owner.props.get("value").filter(|_| show_value) {
892                let text = js(v);
893                self.text_part(head, &owner, "value", &text, "muted", "");
894            }
895        }
896        let mut props = owner.props.clone();
897        props.shift_remove("label");
898        // #VAPP-286: a leading `icon` and/or an `endLabel` put the track in a
899        // `row` (centred, gap sm): the icon (iconSm), the track (grows from a
900        // zero basis), the end label (end-aligned; its min width = the recipe).
901        let icon = str_prop(&owner.props, "icon").filter(|s| !s.is_empty()).map(str::to_string);
902        let end = owner.props.get("endLabel").filter(|v| !v.is_null()).map(js).filter(|s| !s.is_empty());
903        let track = if icon.is_some() || end.is_some() {
904            let row = self.part(&owner, "row", "Box", NodeKind::Container, json!({"display": "flex", "flexDirection": "row", "alignItems": "center", "gap": "$spacing.sm", "alignSelf": "stretch"}), json!({}));
905            if let Some(name) = icon {
906                self.part_in(row, &owner, "icon", "Icon", NodeKind::Leaf, json!({"flexShrink": 0}), json!({"name": name, "size": "sm"}), "");
907            }
908            let track = self.part_in(row, &owner, "track", "Slider", NodeKind::Leaf, json!({"flexGrow": 1, "flexShrink": 1, "flexBasis": 0, "minWidth": 0}), Value::Object(props), "");
909            if let Some(text) = end {
910                self.part_in(row, &owner, "end", "Text", NodeKind::Leaf, json!({"flexShrink": 0, "textAlign": "end"}), json!({"text": text, "variant": "muted", "lines": 1}), "");
911            }
912            track
913        } else {
914            self.part_in(index, &owner, "track", "Slider", NodeKind::Leaf, json!({"alignSelf": "stretch"}), Value::Object(props), "")
915        };
916        self.nodes[track as usize].pressable = !bool_prop(&owner.props, "disabled");
917    }
918
919    /// Form: a column of its children; `summary` lists every failed check
920    /// above them after a refused submit (a live region).
921    pub(crate) fn form(&mut self, index: u32, node: &UiNode, scope: &str) {
922        self.style_default(index, &[("display", json!("flex")), ("flexDirection", json!("column")), ("gap", json!("$spacing.md"))]);
923        let owner = self.nodes[index as usize].clone();
924        self.forms.insert(owner.id.clone(), (bool_prop(&owner.props, "disabled"), bool_prop(&owner.props, "busy")));
925        if bool_prop(&owner.props, "summary") {
926            let mut messages: Vec<String> = Vec::new();
927            for field in self.form_fields_of(node) {
928                if let Some(e) = self.ctx.local.errors.get(&field) {
929                    messages.extend(e.messages.iter().cloned());
930                }
931            }
932            if !messages.is_empty() {
933                let s = self.text_part(index, &owner, "summary", &messages.join("\n"), "body", "");
934                self.nodes[s as usize].live = Some("assertive".into());
935                self.add_state(s, "invalid");
936            }
937        }
938        self.children_of(index, node, scope, false);
939    }
940
941    /// The ids of the named fields a Form collects (not inside a nested Form).
942    pub(crate) fn form_fields_of(&self, node: &UiNode) -> Vec<String> {
943        fn walk(n: &UiNode, out: &mut Vec<String>, top: bool) {
944            if !top && n.component == "Form" {
945                return;
946            }
947            if super::FORM_FIELDS.contains(&n.component.as_str()) && n.props.contains_key("name") {
948                out.push(n.id.clone());
949            }
950            for s in n.slots.iter().flat_map(|s| s.values()) {
951                walk(s, out, false);
952            }
953            for c in &n.children {
954                walk(c, out, false);
955            }
956        }
957        let mut out = Vec::new();
958        walk(node, &mut out, true);
959        out
960    }
961}
962
963/// A ChoiceTiles picture's width / height (`plan-card.json` `tiles.pictureAspect`).
964pub const CHOICE_PICTURE_ASPECT: f64 = 1.6;
965
966/// The values a ChoiceTiles `value` picks: one for `single`, the comma-joined
967/// list for `multiple` (empty entries dropped).
968pub fn choice_values(props: &serde_json::Map<String, Value>) -> Vec<String> {
969    let raw = props.get("value").filter(|v| !v.is_null()).map(js).unwrap_or_default();
970    if props.get("type").and_then(Value::as_str) == Some("multiple") {
971        raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()
972    } else if raw.is_empty() {
973        Vec::new()
974    } else {
975        vec![raw]
976    }
977}
978
979/// A press on option `value`: `single` picks it; `multiple` toggles it in
980/// the list, kept in OPTION order. The new comma-joined value.
981pub fn choice_toggle(props: &serde_json::Map<String, Value>, value: &str) -> String {
982    if props.get("type").and_then(Value::as_str) != Some("multiple") {
983        return value.to_string();
984    }
985    let mut picked = choice_values(props);
986    if let Some(i) = picked.iter().position(|v| v == value) {
987        picked.remove(i);
988    } else {
989        picked.push(value.to_string());
990    }
991    let order: Vec<String> = props.get("options").and_then(Value::as_array).map(|a| a.iter().filter_map(|o| o.get("value")).map(js).collect()).unwrap_or_default();
992    picked.sort_by_key(|v| order.iter().position(|o| o == v).unwrap_or(usize::MAX));
993    picked.join(",")
994}
995
996#[cfg(test)]
997mod tests {
998    use super::dates::*;
999    use super::*;
1000
1001    #[test]
1002    fn numbers_round_like_to_fixed_and_intl() {
1003        // toFixed: the exact binary value, ties away from zero.
1004        assert_eq!(to_fixed(2.5, 0), "3");
1005        assert_eq!(to_fixed(-2.5, 0), "-3");
1006        assert_eq!(to_fixed(0.125, 2), "0.13");
1007        assert_eq!(to_fixed(1.005, 2), "1.00", "1.005 is 1.00499999…");
1008        assert_eq!(to_fixed(0.1 + 0.2, 1), "0.3");
1009        assert_eq!(to_fixed(9.95, 1), "9.9", "9.95 is 9.9499999…");
1010        assert_eq!(to_fixed(99.5, 0), "100");
1011        assert_eq!(to_fixed(-0.04, 1), "-0.0");
1012        assert_eq!(to_fixed(1234.5678, 3), "1234.568");
1013        // Intl: the shortest decimal, ties away from zero, grouped.
1014        assert_eq!(format_number_value(1.005, 2), "1.01");
1015        assert_eq!(format_number_value(2.5, 0), "3");
1016        assert_eq!(format_number_value(1234.55, 1), "1,234.6");
1017        assert_eq!(format_number_value(0.30000000000000004, 1), "0.3");
1018        assert_eq!(format_number_value(999_999.5, 0), "1,000,000");
1019        assert_eq!(format_number_value(-1234.5, 0), "-1,235");
1020        // decimalsOf(String(step)).
1021        assert_eq!(decimals_of(0.1), 1);
1022        assert_eq!(decimals_of(0.25), 2);
1023        assert_eq!(decimals_of(1.0), 0);
1024        assert_eq!(decimals_of(1e-7), 0, "String(1e-7) = \"1e-7\"");
1025        assert_eq!(decimals_of(1.5e-7), 4, "String(1.5e-7) = \"1.5e-7\"");
1026        assert_eq!(clamp_number_value(0.30000000000000004, None, None, 1), 0.3);
1027        assert_eq!(clamp_number_value(2.5, Some(0.0), Some(10.0), 0), 3.0);
1028        assert_eq!(clamp_number_value(42.0, Some(0.0), Some(10.0), 0), 10.0);
1029        let props = |v: serde_json::Value| v.as_object().cloned().unwrap();
1030        assert_eq!(number_step_precision(&props(json!({"step": 0.1}))), (0.1, 1));
1031        assert_eq!(number_step_precision(&props(json!({"step": 0}))), (1.0, 0), "step || 1");
1032        assert_eq!(number_step_precision(&props(json!({"step": 0.5, "precision": 3}))), (0.5, 3));
1033    }
1034
1035    #[test]
1036    fn calendar_arithmetic() {
1037        assert_eq!(weekday(2026, 10, 7), 3, "a Wednesday");
1038        assert_eq!(weekday(2000, 1, 1), 6);
1039        assert_eq!(days_in_month(2024, 2), 29);
1040        assert_eq!(days_in_month(2100, 2), 28);
1041        assert_eq!(add_days(2026, 1, 1, -1), (2025, 12, 31));
1042        assert_eq!(add_days(2026, 2, 27, 3), (2026, 3, 2));
1043        assert_eq!(shift_month(2026, 12, 1), (2027, 1));
1044        assert_eq!(parse("2026-02-30"), None);
1045        assert_eq!(label("2026-10-07"), "Oct 7, 2026");
1046        assert_eq!(parse_time("09:30"), Some(570));
1047        assert_eq!(format_time(570), "09:30");
1048        assert_eq!(format_number_value(2.5, 2), "2.50");
1049        assert_eq!(format_bytes(48213.0), "47.1 kB");
1050        assert_eq!(format_bytes(2048.0), "2 kB");
1051        assert_eq!(format_bytes(500.0), "500 byte");
1052    }
1053}