Skip to main content

teksilo_widgets/
date_time_edit.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DateTimeEdit` — single unified control for picking a `DateTime`.
5//!
6//! Visually one widget: a single bordered frame containing a date
7//! `TextInputField` half, a small painted separator, a time
8//! `TextInputField` half, and a trailing built-in calendar button that
9//! opens a `Calendar` popover anchored below the wrapper. Backed by
10//! `Signal<Option<DateTime>>`.
11//!
12//! ```text
13//! ┌──────────────────────────────────────┐
14//! │ 05/02/2026   ·   14:35   │ 📅       │
15//! └──────────────────────────────────────┘
16//! ```
17//!
18//! # Why one frame?
19//!
20//! Two adjacent `DateEdit` + `TimeEdit` (one frame each) visually read
21//! as two separate fields that happen to be next to each other. A single
22//! frame says "this is one moment in time" — same affordance the user
23//! is used to from booking sites, calendar apps, and form builders.
24//!
25//! # Behaviour
26//!
27//! - **Two text halves** — date pattern on the left (locale-derived
28//!   strftime subset), time pattern on the right (24h or 12h, with or
29//!   without seconds). Each half carries its own input mask, validator,
30//!   and segment-stepping (Up/Down on the focused segment).
31//! - **Painted separator** — a thin middle-dot glyph (`·`), no text.
32//!   Visual only; AT users see the wrapper's `Role::DateTimeInput`. The
33//!   separator can be replaced with a custom string via
34//!   `separator` (rendered as styled secondary text).
35//! - **One trailing calendar button** — Int UI `IconButton::embedded()` with the
36//!   calendar glyph. Opens a single popover hosting `Calendar::single`
37//!   bound to the date half. Picking a cell commits the date and closes
38//!   the popover; the time half retains whatever the user typed.
39//! - **One frame** — focus-aware border (`BorderRole::Focused` while
40//!   any half holds focus, otherwise `Default`), validation-aware
41//!   border (`Error` for `Invalid`, `Focused` for `Corrected`).
42//! - **One validation strip** below the frame — composed feedback from
43//!   both halves (worse of the two wins).
44//!
45//! # Accessibility
46//!
47//! - Container — `Role::DateTimeInput` with `set_value` formatted as
48//!   `YYYY-MM-DDTHH:MM:SS` (ISO 8601 datetime).
49//! - Each `TextInputField` keeps its own `Role::TextInput` AT node;
50//!   the wrapper's `Role::DateTimeInput` provides the datetime semantics.
51//!
52//! ```ignore
53//! // Requires ctx.signal() — shown as ignore per convention.
54//! use teksilo_widgets::date_time_edit::{DateTimeEdit, SecondsMode};
55//!
56//! let datetime = ctx.signal(None);
57//! let _w = DateTimeEdit::new(datetime.clone())
58//!     .seconds(SecondsMode::Hidden)
59//!     .on_value_changed(|dt, _ctx| println!("{dt:?}"));
60//! ```
61
62#[cfg(test)]
63mod tests;
64
65use std::rc::Rc;
66use teksilo_i18n::lit;
67use teksilo_i18n::localized;
68
69use jiff::civil::Weekday;
70use teksilo_canvas::{Path, Point, Rect, SizeProposal};
71use teksilo_core::accessibility::AccessNodeBuilder;
72use teksilo_core::accesskit::{Action, Role};
73use teksilo_core::build_context::BuildContext;
74use teksilo_core::event::{EventResponse, Key, WidgetEvent};
75use teksilo_core::overlay::{
76    DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
77};
78use teksilo_core::signal::{Prop, Signal};
79use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
80use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
81use teksilo_core::widget_id::WidgetId;
82use teksilo_i18n::resolve_message_widget;
83use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
84
85use crate::calendar::Calendar;
86use crate::common::datetime::pattern::{
87    ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
88    segment_at_position, step_date_field, step_time_field,
89};
90use crate::common::datetime::types::today_local;
91use crate::common::datetime::{Date, DateTime, Time};
92use crate::date_edit::{ValidationBehavior, build_date_validator, calendar_glyph_icon, clamp_date};
93use crate::icon_button::{IconButton, IconButtonSize};
94use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
95use crate::primitives::{
96    Center, FixedSize, HStack, IconWidget, MinSize, Padding, RectWidget, TextWidget, VStack, ZStack,
97};
98use crate::time_edit::{
99    SecondsMode, TimeFormat, build_time_validator, clamp_time, time_pattern_for,
100};
101use teksilo_i18n::LocalizedString;
102
103type OnValueChanged = Rc<dyn Fn(Option<DateTime>, &mut EventContext)>;
104
105/// Single unified datetime picker over `Signal<Option<DateTime>>`. See
106/// the [module docs](self) for the visual layout and behaviour.
107pub struct DateTimeEdit {
108    value: Signal<Option<DateTime>>,
109    /// Internal date half — drives the date `TextInputField` text
110    /// signal and is kept in sync with `value` via `ctx.effect`.
111    pub(crate) date_part: Signal<Option<Date>>,
112    pub(crate) time_part: Signal<Option<Time>>,
113    date_text: Signal<String>,
114    time_text: Signal<String>,
115    /// Set by `::required(Signal<DateTime>)`; wired into `ctx.effect`
116    /// in `build()` so observer handles outlive construction.
117    required_source: Option<Signal<DateTime>>,
118    date_format_pattern: Option<String>,
119    /// Explicit 12h/24h override for the time half. `None` (default)
120    /// derives from the current locale via `prefers_12_hour_clock`.
121    time_format: Option<TimeFormat>,
122    seconds: SecondsMode,
123    min: Option<DateTime>,
124    max: Option<DateTime>,
125    step_minutes: u32,
126    first_day_of_week: Option<Weekday>,
127    show_calendar_button: bool,
128    /// Optional separator string between the two halves. When `None`
129    /// (default), a thin painted middle-dot glyph is used. When set,
130    /// the string is rendered as styled secondary text.
131    separator: Option<String>,
132    placeholder: LocalizedString,
133    /// Enabled state, static or reactive; forwarded to the arena at
134    /// build time.
135    enabled: Prop<bool>,
136    read_only: bool,
137    label: Option<LocalizedString>,
138    validation_behavior: ValidationBehavior,
139    /// How the trailing (time) half claims horizontal space. The
140    /// leading (date) half always sizes to its mask-derived natural
141    /// width — the date stays put while the time half either matches
142    /// that natural width (`WidthPolicy::Default`) or absorbs
143    /// extra space (`WidthPolicy::Fill`).
144    time_width_policy: crate::date_edit::WidthPolicy,
145    /// Composed validation feedback (severity-merged from both halves).
146    feedback: Signal<ValidationFeedback>,
147    /// `true` while either half holds keyboard focus — drives the
148    /// unified frame border.
149    focused: Signal<bool>,
150    /// `true` while the calendar popover is open — drives the
151    /// trigger's AT `set_expanded` and the open/close toggle.
152    calendar_popover_open: Signal<bool>,
153    on_value_changed: Option<OnValueChanged>,
154    style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
155    root_child_id: Option<WidgetId>,
156    calendar_id: Option<WidgetId>,
157    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
158    /// with the rich / composite slots — every setter clears the other two so
159    /// the last call wins.
160    tooltip_text: Option<LocalizedString>,
161    /// Optional rich tooltip source (registry key or inline content).
162    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
163    /// Optional composite tooltip body (arbitrary widget tree).
164    composite_tooltip_content: Option<Box<dyn Widget>>,
165}
166
167impl std::fmt::Debug for DateTimeEdit {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.debug_struct("DateTimeEdit").finish_non_exhaustive()
170    }
171}
172
173impl DateTimeEdit {
174    /// Create a datetime picker backed by the optional `value` signal.
175    pub fn new(value: Signal<Option<DateTime>>) -> Self {
176        let initial = value.get();
177        let date_part = Signal::new(initial.map(|dt| dt.date()));
178        let time_part = Signal::new(initial.map(|dt| dt.time()));
179        Self {
180            value,
181            date_part,
182            time_part,
183            date_text: Signal::new(String::new()),
184            time_text: Signal::new(String::new()),
185            required_source: None,
186            date_format_pattern: None,
187            time_format: None,
188            seconds: SecondsMode::Hidden,
189            min: None,
190            max: None,
191            step_minutes: 1,
192            first_day_of_week: None,
193            show_calendar_button: true,
194            separator: None,
195            placeholder: LocalizedString::literal(String::new()),
196            enabled: Prop::Static(true),
197            read_only: false,
198            label: None,
199            validation_behavior: ValidationBehavior::AutoCorrect,
200            time_width_policy: crate::date_edit::WidthPolicy::Default,
201            feedback: Signal::new(ValidationFeedback::Pristine),
202            focused: Signal::new(false),
203            calendar_popover_open: Signal::new(false),
204            on_value_changed: None,
205            style_override: None,
206            root_child_id: None,
207            calendar_id: None,
208            tooltip_text: None,
209            rich_tooltip_source: None,
210            composite_tooltip_content: None,
211        }
212    }
213
214    /// Per-call DateEditStyle override (shared with DateEdit family).
215    pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
216        self.style_override = Some(std::rc::Rc::new(style));
217        self
218    }
219
220    /// Create a datetime picker backed by a *required* (non-optional) signal.
221    /// The widget wraps it in an `Option` proxy internally and keeps the two
222    /// in sync via `ctx.effect` — the outer signal is never set to `None`.
223    pub fn required(value: Signal<DateTime>) -> Self {
224        let proxy: Signal<Option<DateTime>> = Signal::new(Some(value.get()));
225        let mut s = Self::new(proxy);
226        s.required_source = Some(value);
227        s
228    }
229
230    /// Override the strftime-subset format pattern for the date half
231    /// (e.g. `"%d/%m/%Y"`). Defaults to the locale-derived pattern.
232    pub fn date_format_pattern(mut self, p: impl Into<String>) -> Self {
233        self.date_format_pattern = Some(p.into());
234        self
235    }
236
237    /// Lock the time half to a specific clock (12h or 24h). When this
238    /// builder is *not* called, the time half defaults to the user's
239    /// current locale via `prefers_12_hour_clock` — same rule as
240    /// standalone `TimeEdit`.
241    pub fn time_format(mut self, f: TimeFormat) -> Self {
242        self.time_format = Some(f);
243        self
244    }
245
246    /// Whether the time half includes a seconds field. Defaults to `SecondsMode::Hidden`.
247    pub fn seconds(mut self, mode: SecondsMode) -> Self {
248        self.seconds = mode;
249        self
250    }
251
252    /// Earliest selectable datetime (inclusive). Both the calendar cell and the
253    /// text validator enforce this floor.
254    pub fn min(mut self, dt: DateTime) -> Self {
255        self.min = Some(dt);
256        self
257    }
258
259    /// Latest selectable datetime (inclusive). Both the calendar cell and the
260    /// text validator enforce this ceiling.
261    pub fn max(mut self, dt: DateTime) -> Self {
262        self.max = Some(dt);
263        self
264    }
265
266    /// Minute increment for Up/Down segment stepping on the minute field.
267    /// Defaults to `1`; values below `1` are clamped to `1`.
268    pub fn step_minutes(mut self, n: u32) -> Self {
269        self.step_minutes = n.max(1);
270        self
271    }
272
273    /// Override which weekday appears in the first column of the calendar popup.
274    pub fn first_day_of_week(mut self, w: Weekday) -> Self {
275        self.first_day_of_week = Some(w);
276        self
277    }
278
279    /// Show or hide the trailing calendar button. Default `true`.
280    pub fn show_calendar_button(mut self, show: bool) -> Self {
281        self.show_calendar_button = show;
282        self
283    }
284
285    /// Override the painted middle-dot separator with a custom string
286    /// (rendered as styled secondary text between the two halves).
287    /// Pass an empty string to suppress the separator entirely.
288    pub fn separator(mut self, s: impl Into<String>) -> Self {
289        self.separator = Some(s.into());
290        self
291    }
292
293    /// Placeholder shown when the datetime is `None`.
294    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
295        let ls: LocalizedString = text.into();
296        self.placeholder = ls;
297        self
298    }
299
300    /// Set the enabled state, statically or reactively. Forwarded to the
301    /// arena at build time.
302    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
303        self.enabled = enabled.into();
304        self
305    }
306
307    /// Make both halves read-only; the calendar button is also disabled.
308    pub fn read_only(mut self, read_only: bool) -> Self {
309        self.read_only = read_only;
310        self
311    }
312
313    /// Accessible label for the wrapper `Role::DateTimeInput` node. When not
314    /// set, falls back to the localized `date-time-edit-name` message.
315    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
316        let ls: LocalizedString = label.into();
317        self.label = Some(ls);
318        self
319    }
320
321    /// How parse failures are surfaced. Forwarded to both halves —
322    /// each half uses the same behaviour.
323    pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
324        self.validation_behavior = behavior;
325        self
326    }
327
328    /// How the trailing (time) half claims horizontal space. The
329    /// leading (date) half always sizes to its natural mask width;
330    /// the time half follows this policy. Default
331    /// `WidthPolicy::Default` (natural width); pass
332    /// `WidthPolicy::Fill` to make the time half absorb extra
333    /// space the parent offers.
334    pub fn time_width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
335        self.time_width_policy = policy;
336        self
337    }
338
339    /// Reactive handle on the composed validation feedback. Reflects
340    /// whichever half is more severe (`Invalid > Corrected > Valid >
341    /// Pristine`).
342    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
343        self.feedback.clone()
344    }
345
346    /// Callback invoked whenever the datetime changes. Receives the new
347    /// `Option<DateTime>` and an `EventContext` for dispatching intents.
348    pub fn on_value_changed(
349        mut self,
350        f: impl Fn(Option<DateTime>, &mut EventContext) + 'static,
351    ) -> Self {
352        self.on_value_changed = Some(Rc::new(f));
353        self
354    }
355
356    /// Show a plain single-line tooltip after a hover delay. Mutually
357    /// exclusive with `rich_tooltip` / `rich_tooltip_content` /
358    /// `composite_tooltip` — each setter clears the other three so the
359    /// last call wins.
360    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
361        self.tooltip_text = Some(text.into());
362        self.rich_tooltip_source = None;
363        self.composite_tooltip_content = None;
364        self
365    }
366
367    /// Show a rich tooltip identified by a registry key. Mutually
368    /// exclusive with `tooltip` / `rich_tooltip_content` /
369    /// `composite_tooltip` — each setter clears the other three so the
370    /// last call wins.
371    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
372        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
373        self.tooltip_text = None;
374        self.composite_tooltip_content = None;
375        self
376    }
377
378    /// Show a rich tooltip with inline content. Mutually exclusive with
379    /// `tooltip` / `rich_tooltip` / `composite_tooltip` — each setter
380    /// clears the other three so the last call wins.
381    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
382        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
383        self.tooltip_text = None;
384        self.composite_tooltip_content = None;
385        self
386    }
387
388    /// Show a composite tooltip whose body is an arbitrary widget tree.
389    /// Mutually exclusive with `tooltip` / `rich_tooltip` /
390    /// `rich_tooltip_content` — each setter clears the other three so
391    /// the last call wins.
392    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
393        self.composite_tooltip_content = Some(Box::new(content));
394        self.tooltip_text = None;
395        self.rich_tooltip_source = None;
396        self
397    }
398
399    /// Clone the underlying `Signal<Option<DateTime>>` for external binding.
400    pub fn value(&self) -> Signal<Option<DateTime>> {
401        self.value.clone()
402    }
403}
404
405impl Widget for DateTimeEdit {
406    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
407        let theme = ctx.theme_signal().get();
408        use crate::styles::recipe_date_edit_style as de;
409        use crate::styles::recipe_text_input_style as field_dims;
410        let focus_ring_width = theme.shape.focus_ring_width;
411        let self_id = ctx.self_id();
412        // Forward the enabled state into the arena; see IconButton.
413        ctx.enabled_when(self_id, self.enabled.clone());
414        let read_only = self.read_only;
415
416        // ── required-source mirror via ctx.effect ─────────────
417        if let Some(src) = self.required_source.clone() {
418            {
419                let proxy = self.value.clone();
420                ctx.effect(&src, move |new| {
421                    if proxy.get() != Some(*new) {
422                        proxy.set(Some(*new));
423                    }
424                });
425            }
426            {
427                let src_clone = src;
428                ctx.effect(&self.value, move |v| {
429                    if let Some(dt) = v
430                        && src_clone.get() != *dt
431                    {
432                        src_clone.set(*dt);
433                    }
434                });
435            }
436        }
437
438        // ── Resolve patterns ───────────────────────────────────
439        // A locale switch must re-derive the date pattern and the 12-vs-24-hour clock: it is read from
440        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
441        // only calls `mark_all_dirty` (layout + paint), which never re-runs
442        // `build()`. Without this binding the widget keeps rendering with
443        // the pattern of whatever locale was active when it was first
444        // built. Bound at `Rebuild` for the same reason `Calendar` binds
445        // the text scale there — the value is a build-time constant, so a
446        // relayout cannot pick it up.
447        ctx.locale_signal().bind_to(
448            ctx.self_id(),
449            ctx.binding_registry(),
450            teksilo_core::binding::BindingLevel::Rebuild,
451        );
452
453        let date_pattern_string = self.date_format_pattern.clone().unwrap_or_else(|| {
454            let tag = ctx.locale_signal().get().unwrap_or_default();
455            crate::common::datetime::format_pattern_for_locale(&tag).to_string()
456        });
457        let date_pattern = ParsedPattern::parse(&date_pattern_string)
458            .unwrap_or_else(|_| ParsedPattern::parse("%Y-%m-%d").unwrap());
459        let date_pattern_rc = Rc::new(date_pattern);
460        let date_mask = mask_for_pattern(&date_pattern_rc);
461
462        let time_format = self.time_format.unwrap_or_else(|| {
463            let tag = ctx.locale_signal().get().unwrap_or_default();
464            if crate::common::datetime::prefers_12_hour_clock(&tag) {
465                TimeFormat::Hour12
466            } else {
467                TimeFormat::Hour24
468            }
469        });
470        let time_pattern_string = time_pattern_for(time_format, self.seconds);
471        let time_pattern = ParsedPattern::parse(&time_pattern_string)
472            .unwrap_or_else(|_| ParsedPattern::parse("%H:%M").unwrap());
473        let time_pattern_rc = Rc::new(time_pattern);
474        let time_mask = mask_for_pattern(&time_pattern_rc);
475
476        let date_min = self.min.map(|dt| dt.date());
477        let date_max = self.max.map(|dt| dt.date());
478        let time_min = self.min.map(|dt| dt.time());
479        let time_max = self.max.map(|dt| dt.time());
480
481        // ── outer → halves mirror ─────────────────────────────
482        // External writes split into halves AND reformat their text so
483        // the visible field reflects the new value (programmatic
484        // `value.set(...)` should update both the internal date/time
485        // signals AND the field's text).
486        {
487            let date_part = self.date_part.clone();
488            let time_part = self.time_part.clone();
489            let date_text = self.date_text.clone();
490            let time_text = self.time_text.clone();
491            let date_pattern = date_pattern_rc.clone();
492            let time_pattern = time_pattern_rc.clone();
493            ctx.effect(&self.value, move |new_dt| {
494                let new_d = new_dt.map(|dt| dt.date());
495                let new_t = new_dt.map(|dt| dt.time());
496                if date_part.get() != new_d {
497                    date_part.set(new_d);
498                }
499                if time_part.get() != new_t {
500                    time_part.set(new_t);
501                }
502                let d_text = new_d
503                    .map(|d| format_value(&date_pattern, Some(d), None))
504                    .unwrap_or_default();
505                let t_text = new_t
506                    .map(|t| format_value(&time_pattern, None, Some(t)))
507                    .unwrap_or_default();
508                if date_text.get() != d_text {
509                    date_text.set(d_text);
510                }
511                if time_text.get() != t_text {
512                    time_text.set(t_text);
513                }
514            });
515        }
516        // Seed text once at build time so the initial value is visible
517        // without waiting for the first effect tick.
518        {
519            self.date_text.set(
520                self.date_part
521                    .get()
522                    .map(|d| format_value(&date_pattern_rc, Some(d), None))
523                    .unwrap_or_default(),
524            );
525            self.time_text.set(
526                self.time_part
527                    .get()
528                    .map(|t| format_value(&time_pattern_rc, None, Some(t)))
529                    .unwrap_or_default(),
530            );
531        }
532
533        // ── Build each half as a bare TextInputField ───────────
534        // Each half returns (layout wrapper, inner editable field id).
535        let (date_field_id, date_inner_id) =
536            self.build_date_half(ctx, date_pattern_rc.clone(), &date_mask, date_min, date_max);
537        let (time_field_id, time_inner_id) =
538            self.build_time_half(ctx, time_pattern_rc.clone(), &time_mask, time_min, time_max);
539
540        // ── Painted (or text) separator ────────────────────────
541        // Default: thin painted middle-dot glyph. Apps that want a
542        // different shape can pass `.separator("…")` to render that
543        // string as styled text instead.
544        let separator_id = match self.separator.as_deref() {
545            None => {
546                let dot = middle_dot_icon(field_dims::TEXT_FIELD_HEIGHT * 0.4)
547                    .color(teksilo_tokens::TextRole::Secondary);
548                ctx.add(
549                    FixedSize::new()
550                        .width(field_dims::TEXT_FIELD_HEIGHT * 0.55)
551                        .height(field_dims::TEXT_FIELD_HEIGHT)
552                        .child(Center::new().child(dot)),
553                )
554            }
555            Some(s) if s.is_empty() => ctx.add(
556                FixedSize::new()
557                    .width(0.0_f32)
558                    .height(field_dims::TEXT_FIELD_HEIGHT),
559            ),
560            Some(s) => {
561                let text = TextWidget::new(lit!(s))
562                    .style(teksilo_tokens::TextStyleRole::Body)
563                    .color(teksilo_tokens::TextRole::Secondary)
564                    .single_line()
565                    .a11y_hidden();
566                ctx.add(Padding::new(0.0, 6.0, 0.0, 6.0).child(Center::new().child(text)))
567            }
568        };
569
570        // ── Trailing calendar trigger (date-only) ──────────────
571        let trigger_id_opt = if self.show_calendar_button {
572            // Bridge signal: the calendar binds to a parallel
573            // `Signal<Option<Date>>` so its internal cell-render +
574            // arrow-key state can mutate freely; the popover commit
575            // path writes the final selection through us.
576            let calendar_temp: Signal<Option<Date>> = Signal::new(self.date_part.get());
577            {
578                let temp = calendar_temp.clone();
579                ctx.effect(&self.date_part, move |new_d| {
580                    if temp.get() != *new_d {
581                        temp.set(*new_d);
582                    }
583                });
584            }
585            let popover_open = self.calendar_popover_open.clone();
586            let date_part = self.date_part.clone();
587            let date_text = self.date_text.clone();
588            let date_pattern = date_pattern_rc.clone();
589            let value_outer = self.value.clone();
590            let time_part = self.time_part.clone();
591            let on_changed = self.on_value_changed.clone();
592            let return_focus_to = ctx.self_id();
593            let mut calendar =
594                Calendar::single(calendar_temp.clone()).on_activate(move |d, ctx_evt| {
595                    let clamped = clamp_date(d, date_min, date_max);
596                    date_part.set(Some(clamped));
597                    date_text.set(format_value(&date_pattern, Some(clamped), None));
598                    let combined = match (Some(clamped), time_part.get()) {
599                        (Some(d), Some(t)) => Some(d.to_datetime(t)),
600                        _ => None,
601                    };
602                    if value_outer.get() != combined {
603                        value_outer.set(combined);
604                        if let Some(cb) = on_changed.as_ref() {
605                            cb(combined, ctx_evt);
606                        }
607                    }
608                    popover_open.set(false);
609                    ctx_evt.dismiss_self_overlay_chain();
610                    ctx_evt.request_focus(return_focus_to);
611                    ctx_evt.request_frame();
612                });
613            if let Some(min) = date_min {
614                calendar = calendar.min_date(min);
615            }
616            if let Some(max) = date_max {
617                calendar = calendar.max_date(max);
618            }
619            if let Some(fdow) = self.first_day_of_week {
620                calendar = calendar.first_day_of_week(fdow);
621            }
622            // Built the first time the popup is opened, not on every rebuild of the
623            // field. See `teksilo_core::deferred_subtree::DeferredSubtree`.
624            let cal_id = ctx.add_deferred(self.calendar_popover_open.clone(), calendar);
625            ctx.set_dormant(cal_id);
626            self.calendar_id = Some(cal_id);
627
628            let popover_open = self.calendar_popover_open.clone();
629            let self_ref = ctx.self_id();
630            let dismiss_cb: OverlayDismissCallback = {
631                let popover_open = popover_open.clone();
632                Rc::new(move || {
633                    popover_open.set(false);
634                })
635            };
636            let trigger_enabled = self.enabled.as_signal().map(move |on| *on && !read_only);
637            let trigger_btn = IconButton::new(calendar_glyph_icon(de::CALENDAR_ICON_SIZE))
638                .embedded()
639                .size(IconButtonSize::Default)
640                .enabled(trigger_enabled)
641                .tooltip(localized(move || {
642                    resolve_message_widget("date-time-edit-trigger-tooltip", &[])
643                }))
644                .on_activate_fn(move |ctx_evt: &mut EventContext| {
645                    if popover_open.get() {
646                        popover_open.set(false);
647                        ctx_evt.dismiss_all_except_hosts();
648                    } else {
649                        popover_open.set(true);
650                        // Build the popup if this is its first open, before the overlay
651                        // below is measured against it and focus moves into it.
652                        ctx_evt.materialize_now(cal_id);
653                        ctx_evt.activate(cal_id);
654                        ctx_evt.show_overlay(OverlayRequest {
655                            content_id: cal_id,
656                            anchor: self_ref,
657                            placement: OverlayPlacement::BelowPreferred,
658                            dismiss: DismissBehavior::EscapeOrClickOutside,
659                            layer: OverlayLayer::InTree,
660                            parent_overlay: None,
661                            on_dismiss: Some(dismiss_cb.clone()),
662                            fade_duration: None,
663                        });
664                        ctx_evt.request_focus(cal_id);
665                    }
666                });
667            Some(ctx.add(trigger_btn))
668        } else {
669            None
670        };
671
672        // ── Row layout ─────────────────────────────────────────
673        // Each half is wrapped in `Shrinkable` so the row can compress them
674        // when the unified frame is narrower than the combined natural mask
675        // width — the `TextInputField` inside then scrolls its text instead of
676        // overflowing the layout. `Shrinkable` preserves each half's natural
677        // width when there's room, so the wide-case layout (date at natural
678        // width, time fixed/Fill) is unchanged.
679        let date_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child_id(date_field_id));
680        let time_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child_id(time_field_id));
681        let mut row = HStack::new()
682            .spacing(0.0)
683            .add_child(date_shrinkable)
684            .add_child(separator_id)
685            .add_child(time_shrinkable);
686        if let Some(trigger_id) = trigger_id_opt {
687            row = row.add_child(trigger_id);
688        }
689        let inline_row_id = ctx.add(row);
690        let row_id = ctx.add(
691            Padding::new(
692                0.0,
693                field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
694                0.0,
695                field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
696            )
697            .child_id(inline_row_id),
698        );
699
700        // ── Frame: bg + border driven by focus + validation ───
701        let feedback_for_border = self.feedback.clone();
702        let focused_for_border = self.focused.clone();
703        let border_role =
704            focused_for_border
705                .clone()
706                .zip(&feedback_for_border)
707                .map(|(focused, fb)| match fb {
708                    ValidationFeedback::Invalid { .. } => BorderRole::Error,
709                    ValidationFeedback::Corrected { .. } if !*focused => BorderRole::Focused,
710                    _ => {
711                        if *focused {
712                            BorderRole::Focused
713                        } else {
714                            BorderRole::Default
715                        }
716                    }
717                });
718        let border_width_signal =
719            focused_for_border
720                .clone()
721                .zip(&feedback_for_border)
722                .map(move |(focused, fb)| {
723                    if *focused || matches!(fb, ValidationFeedback::Invalid { .. }) {
724                        focus_ring_width
725                    } else {
726                        field_dims::TEXT_FIELD_BORDER_WIDTH
727                    }
728                });
729        let bg = RectWidget::new()
730            .background(SurfaceRole::Content)
731            .border_color(border_role)
732            .border_width(border_width_signal)
733            .corner_radius(CornerRadius::uniform(field_dims::TEXT_FIELD_CORNER_RADIUS));
734        let bg_id = ctx.add(bg);
735        let framed_id = ctx.add(ZStack::new().add_child(bg_id).add_child(row_id));
736        let sized_id =
737            ctx.add(MinSize::new(0.0, field_dims::TEXT_FIELD_HEIGHT).child_id(framed_id));
738
739        // ── Inline validation strip below the frame ───────────
740        let strip_id = ctx.add(crate::primitives::ValidationStrip::new(
741            self.feedback.clone(),
742        ));
743        // WCAG 3.3.1 / 3.3.3: both editable halves are described by the shared
744        // validation message, announced on either when it gains focus.
745        ctx.access_described_by(date_inner_id, strip_id);
746        ctx.access_described_by(time_inner_id, strip_id);
747        // Wrap the frame in `Expand::horizontal().respect_intrinsic()` so it
748        // claims the VStack's full width (a VStack lays a child out at its own
749        // measured width, not stretched). `respect_intrinsic` keeps the frame's
750        // natural width as the basis when unconstrained, so the widget reports
751        // its natural mask width rather than collapsing; a bounded proposal
752        // narrows it and the `Shrinkable` halves compress to fit.
753        let framed_in_vstack = ctx.add(
754            crate::primitives::Expand::horizontal()
755                .respect_intrinsic()
756                .child_id(sized_id),
757        );
758        let root_with_strip = ctx.add(
759            VStack::new()
760                .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
761                .add_child(framed_in_vstack)
762                .add_child(strip_id),
763        );
764        let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
765            &self.style_override,
766            ctx,
767        );
768        let cfg = teksilo_core::styles::DateEditStyleConfig {
769            body: root_with_strip,
770        };
771        let root_id = style.make_body(&cfg, ctx);
772        self.root_child_id = Some(root_id);
773
774        // ── Tooltip attachment ─────────────────────────────────
775        if let Some(content) = self.composite_tooltip_content.take() {
776            let delay = ctx.theme().motion.tooltip_delay_heavy;
777            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
778        } else if let Some(source) = self.rich_tooltip_source.clone() {
779            let delay = ctx.theme().motion.tooltip_delay;
780            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
781        } else if let Some(text) = self.tooltip_text.clone() {
782            let delay = ctx.theme().motion.tooltip_delay;
783            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
784        }
785
786        // ── Self handlers: focus_within drives the frame border ─
787        let handlers = HandlerSet::new().focus_within(self.focused.clone());
788        ctx.apply_self_handlers(handlers);
789
790        // Bind reactive sources at AccessibilityOnly so the wrapper's
791        // AT node refreshes set_value / Invalid / set_expanded when
792        // the underlying signals change.
793        let self_id = ctx.self_id();
794        self.value.bind_to(
795            self_id,
796            ctx.binding_registry(),
797            teksilo_core::binding::BindingLevel::AccessibilityOnly,
798        );
799        self.feedback.bind_to(
800            self_id,
801            ctx.binding_registry(),
802            teksilo_core::binding::BindingLevel::AccessibilityOnly,
803        );
804        self.calendar_popover_open.bind_to(
805            self_id,
806            ctx.binding_registry(),
807            teksilo_core::binding::BindingLevel::AccessibilityOnly,
808        );
809
810        // Return BOTH the visible root AND the dormant calendar
811        // popover content as children so the framework links
812        // `calendar_id` under this widget in the arena instead of
813        // leaving it an orphan root. See popover_widget.rs for the
814        // same pattern.
815        let mut out = vec![root_with_strip];
816        if let Some(cal_id) = self.calendar_id {
817            out.push(cal_id);
818        }
819        out
820    }
821
822    fn layout_response(
823        &self,
824        proposal: SizeProposal,
825        ctx: &LayoutContext,
826    ) -> teksilo_core::widget::LayoutResponse {
827        // Forward the inner LayoutResponse, then overlay flex=1 when
828        // the time half is Fill — the inner HStack consumes the
829        // Expand's flex and reports flex=0 to its parent, so the
830        // outer wrapper has to advertise flex explicitly.
831        let response = match self.root_child_id {
832            Some(id) => ctx
833                .child_layout_response(id, proposal)
834                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
835            None => proposal.resolve(0.0, 0.0).into(),
836        };
837        if self.time_width_policy == crate::date_edit::WidthPolicy::Fill {
838            teksilo_core::widget::LayoutResponse::flexible(response.size, 1.0)
839        } else {
840            response
841        }
842    }
843
844    fn place_children(
845        &self,
846        bounds: Rect,
847        _proposal: SizeProposal,
848        children: &mut [WidgetPlacement],
849        _ctx: &LayoutContext,
850    ) {
851        // The visible root fills our bounds; the calendar popover's
852        // bounds are owned by the overlay manager when shown
853        // (`position_overlays`), so we zero-size it here.
854        for child in children.iter_mut() {
855            if Some(child.id) == self.calendar_id {
856                child.size = teksilo_canvas::Size::ZERO;
857                continue;
858            }
859            child.origin = bounds.origin();
860            child.size = bounds.size();
861        }
862    }
863
864    fn children(&self) -> Vec<WidgetId> {
865        let mut out = Vec::new();
866        if let Some(id) = self.root_child_id {
867            out.push(id);
868        }
869        if let Some(id) = self.calendar_id {
870            out.push(id);
871        }
872        out
873    }
874
875    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
876        builder.set_role(Role::DateTimeInput);
877        if let Some(ref label) = self.label {
878            builder.set_name(label.clone());
879        } else {
880            builder.set_name(resolve_message_widget("date-time-edit-name", &[]));
881        }
882        match self.value.get() {
883            Some(dt) => {
884                builder.set_value(format!(
885                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
886                    dt.date().year(),
887                    dt.date().month(),
888                    dt.date().day(),
889                    dt.time().hour(),
890                    dt.time().minute(),
891                    dt.time().second(),
892                ));
893            }
894            None => {
895                if !self.placeholder.resolve_now().is_empty() {
896                    builder.set_placeholder(self.placeholder.resolve_now());
897                } else {
898                    builder
899                        .set_placeholder(resolve_message_widget("date-time-edit-placeholder", &[]));
900                }
901            }
902        }
903        // Framework a11y walker sets `set_disabled` from arena state.
904        if self.read_only {
905            builder.set_read_only();
906        }
907        if matches!(self.feedback.get(), ValidationFeedback::Invalid { .. }) {
908            builder
909                .inner_mut()
910                .set_invalid(teksilo_core::accesskit::Invalid::True);
911        }
912        if self.show_calendar_button {
913            builder
914                .inner_mut()
915                .set_has_popup(teksilo_core::accesskit::HasPopup::Grid);
916            builder.set_expanded(self.calendar_popover_open.get());
917        }
918        builder.add_action(Action::Focus);
919    }
920}
921
922impl DateTimeEdit {
923    /// Build the date half as a bare `TextInputField` with mask +
924    /// validator + segment-stepping. Returns the WidgetId wrapped in a
925    /// fixed-width container so the half visually aligns inside the
926    /// unified frame.
927    fn build_date_half(
928        &self,
929        ctx: &mut BuildContext,
930        pattern_rc: Rc<ParsedPattern>,
931        mask_string: &str,
932        min: Option<Date>,
933        max: Option<Date>,
934    ) -> (WidgetId, WidgetId) {
935        let validator =
936            build_date_validator(pattern_rc.clone(), min, max, self.validation_behavior);
937
938        let outer_value = self.value.clone();
939        let on_changed = self.on_value_changed.clone();
940        let time_part = self.time_part.clone();
941        let merge_into_outer = move |new_d: Option<Date>, ctx_evt: &mut EventContext| {
942            let combined = match (new_d, time_part.get()) {
943                (Some(d), Some(t)) => Some(d.to_datetime(t)),
944                _ => None,
945            };
946            if outer_value.get() != combined {
947                outer_value.set(combined);
948                if let Some(cb) = on_changed.as_ref() {
949                    cb(combined, ctx_evt);
950                }
951            }
952        };
953
954        let date_signal = self.date_part.clone();
955        let text_signal = self.date_text.clone();
956
957        let commit: Rc<dyn Fn(&mut EventContext)> = {
958            let text_signal = text_signal.clone();
959            let date_signal = date_signal.clone();
960            let pattern = pattern_rc.clone();
961            let merge = merge_into_outer.clone();
962            Rc::new(move |ctx_evt: &mut EventContext| {
963                let raw = text_signal.get();
964                let trimmed = raw.trim();
965                let parsed: Option<Date> = if trimmed.is_empty() {
966                    None
967                } else {
968                    match parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
969                        Some(ParsedValue::Date(d)) => Some(clamp_date(d, min, max)),
970                        _ => date_signal.get(),
971                    }
972                };
973                if date_signal.get() != parsed {
974                    date_signal.set(parsed);
975                }
976                merge(parsed, ctx_evt);
977            })
978        };
979
980        self.build_field(
981            ctx,
982            text_signal.clone(),
983            mask_string,
984            validator,
985            self.placeholder.clone(),
986            commit,
987            "date-time-edit-date-name",
988            Role::DateInput,
989            DateTimeHalfKind::Date {
990                pattern: pattern_rc,
991                date_signal,
992                text_signal,
993                min,
994                max,
995                merge: Rc::new(merge_into_outer),
996            },
997        )
998    }
999
1000    fn build_time_half(
1001        &self,
1002        ctx: &mut BuildContext,
1003        pattern_rc: Rc<ParsedPattern>,
1004        mask_string: &str,
1005        min: Option<Time>,
1006        max: Option<Time>,
1007    ) -> (WidgetId, WidgetId) {
1008        let validator =
1009            build_time_validator(pattern_rc.clone(), min, max, self.validation_behavior);
1010
1011        let outer_value = self.value.clone();
1012        let on_changed = self.on_value_changed.clone();
1013        let date_part = self.date_part.clone();
1014        let merge_into_outer = move |new_t: Option<Time>, ctx_evt: &mut EventContext| {
1015            let combined = match (date_part.get(), new_t) {
1016                (Some(d), Some(t)) => Some(d.to_datetime(t)),
1017                _ => None,
1018            };
1019            if outer_value.get() != combined {
1020                outer_value.set(combined);
1021                if let Some(cb) = on_changed.as_ref() {
1022                    cb(combined, ctx_evt);
1023                }
1024            }
1025        };
1026
1027        let time_signal = self.time_part.clone();
1028        let text_signal = self.time_text.clone();
1029
1030        let commit: Rc<dyn Fn(&mut EventContext)> = {
1031            let text_signal = text_signal.clone();
1032            let time_signal = time_signal.clone();
1033            let pattern = pattern_rc.clone();
1034            let merge = merge_into_outer.clone();
1035            Rc::new(move |ctx_evt: &mut EventContext| {
1036                let raw = text_signal.get();
1037                let trimmed = raw.trim();
1038                let parsed: Option<Time> = if trimmed.is_empty() {
1039                    None
1040                } else {
1041                    match parse_value(&pattern, trimmed, ParseTarget::TimeOnly) {
1042                        Some(ParsedValue::Time(t)) => Some(clamp_time(t, min, max)),
1043                        _ => time_signal.get(),
1044                    }
1045                };
1046                if time_signal.get() != parsed {
1047                    time_signal.set(parsed);
1048                }
1049                merge(parsed, ctx_evt);
1050            })
1051        };
1052
1053        self.build_field(
1054            ctx,
1055            text_signal.clone(),
1056            mask_string,
1057            validator,
1058            LocalizedString::literal(String::new()),
1059            commit,
1060            "date-time-edit-time-name",
1061            Role::TimeInput,
1062            DateTimeHalfKind::Time {
1063                pattern: pattern_rc,
1064                time_signal,
1065                text_signal,
1066                min,
1067                max,
1068                merge: Rc::new(merge_into_outer),
1069            },
1070        )
1071    }
1072
1073    /// Shared frame around one half: configures the `TextInputField`
1074    /// (mask, validator, char filter, commit handlers, a11y), captures
1075    /// caret accessors for segment-stepping, and wraps in a fixed-width
1076    /// stepping ancestor that intercepts arrow / page keys.
1077    #[allow(clippy::too_many_arguments)]
1078    fn build_field(
1079        &self,
1080        ctx: &mut BuildContext,
1081        text_signal: Signal<String>,
1082        mask_string: &str,
1083        validator: crate::primitives::text_input_field::ValidatorFn,
1084        placeholder: LocalizedString,
1085        commit: Rc<dyn Fn(&mut EventContext)>,
1086        a11y_label_key: &str,
1087        a11y_role: Role,
1088        kind: DateTimeHalfKind,
1089    ) -> (WidgetId, WidgetId) {
1090        use crate::styles::recipe_text_input_style as field_dims;
1091        let inner_height =
1092            (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
1093        let text_area_height =
1094            (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
1095
1096        let pattern_for_filter = match &kind {
1097            DateTimeHalfKind::Date { pattern, .. } => pattern.clone(),
1098            DateTimeHalfKind::Time { pattern, .. } => pattern.clone(),
1099        };
1100        let is_time_half = matches!(kind, DateTimeHalfKind::Time { .. });
1101        let mut field = TextInputField::new(text_signal.clone())
1102            .enabled(self.enabled.clone())
1103            .read_only(self.read_only)
1104            .placeholder(placeholder)
1105            .text_height(text_area_height)
1106            .input_mask(mask_string)
1107            .validator({
1108                let v = validator.clone();
1109                move |s| (v)(s)
1110            })
1111            .char_filter(move |c: char| {
1112                if c.is_ascii_digit() || c == ' ' || c == ':' || c == '-' {
1113                    return true;
1114                }
1115                if is_time_half && matches!(c, 'a' | 'A' | 'p' | 'P' | 'm' | 'M') {
1116                    return true;
1117                }
1118                for tok in &pattern_for_filter.tokens {
1119                    if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
1120                        && s.chars().any(|x| x == c)
1121                    {
1122                        return true;
1123                    }
1124                }
1125                false
1126            });
1127        // Mirror this half's feedback into the composed feedback signal
1128        // (worse-of-two — both halves install this and the worse always
1129        // wins because each effect computes max(self, current composed)).
1130        {
1131            let inner_feedback = field.validation_feedback_signal();
1132            let composed = self.feedback.clone();
1133            ctx.effect(&inner_feedback, move |new_fb| {
1134                let merged = match (composed.get(), new_fb.clone()) {
1135                    (a, b) if rank(&a) >= rank(&b) => a,
1136                    (_, b) => b,
1137                };
1138                if composed.get() != merged {
1139                    composed.set(merged);
1140                }
1141            });
1142        }
1143        {
1144            let commit = commit.clone();
1145            field = field.on_submit_fn(move |ctx_evt| commit(ctx_evt));
1146        }
1147        {
1148            let commit = commit.clone();
1149            field = field.on_blur_fn(move |ctx_evt| commit(ctx_evt));
1150        }
1151
1152        let caret = field.caret_position();
1153        let caret_setter = field.caret_setter();
1154
1155        let field_with_a11y = field
1156            .access_role(a11y_role)
1157            .access_label(resolve_message_widget(a11y_label_key, &[]));
1158        let field_id = ctx.add(field_with_a11y);
1159
1160        let padded_field_id = ctx.add(
1161            Padding::new(
1162                field_dims::TEXT_FIELD_PADDING_VERTICAL,
1163                4.0,
1164                field_dims::TEXT_FIELD_PADDING_VERTICAL,
1165                4.0,
1166            )
1167            .child_id(field_id),
1168        );
1169        // Width policy: date (leading) half is always at its natural
1170        // mask width; time (trailing) half follows `time_width_policy`.
1171        // `Default` matches the date — the time stays fixed. `Fill`
1172        // wraps in `Expand::horizontal()` (zero-basis flex=1) so the
1173        // time half absorbs the unified frame's leftover width.
1174        let is_time = matches!(kind, DateTimeHalfKind::Time { .. });
1175        let sized_field_id =
1176            if is_time && self.time_width_policy == crate::date_edit::WidthPolicy::Fill {
1177                ctx.add(crate::primitives::Expand::horizontal().child_id(padded_field_id))
1178            } else {
1179                padded_field_id
1180            };
1181
1182        // ── Segment-stepping (Up/Down/PageUp/PageDown on focused
1183        //    segment) ─────────────────────────────────────────
1184        let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = match kind {
1185            DateTimeHalfKind::Date {
1186                pattern,
1187                date_signal,
1188                text_signal,
1189                min,
1190                max,
1191                merge,
1192            } => {
1193                let caret = caret.clone();
1194                let caret_setter = caret_setter.clone();
1195                Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
1196                    let pos = caret.get();
1197                    let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
1198                        return;
1199                    };
1200                    let current = date_signal.get().unwrap_or_else(today_local);
1201                    let stepped = step_date_field(current, kind_seg, delta);
1202                    let clamped = clamp_date(stepped, min, max);
1203                    date_signal.set(Some(clamped));
1204                    text_signal.set(format_value(&pattern, Some(clamped), None));
1205                    caret_setter(pos);
1206                    merge(Some(clamped), ctx_evt);
1207                    ctx_evt.request_frame();
1208                })
1209            }
1210            DateTimeHalfKind::Time {
1211                pattern,
1212                time_signal,
1213                text_signal,
1214                min,
1215                max,
1216                merge,
1217            } => {
1218                let caret = caret.clone();
1219                let caret_setter = caret_setter.clone();
1220                Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
1221                    let pos = caret.get();
1222                    let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
1223                        return;
1224                    };
1225                    let current = time_signal.get().unwrap_or_else(Time::midnight);
1226                    let stepped = step_time_field(current, kind_seg, delta);
1227                    let clamped = clamp_time(stepped, min, max);
1228                    time_signal.set(Some(clamped));
1229                    text_signal.set(format_value(&pattern, None, Some(clamped)));
1230                    caret_setter(pos);
1231                    merge(Some(clamped), ctx_evt);
1232                    ctx_evt.request_frame();
1233                })
1234            }
1235        };
1236
1237        // No manual `enabled` gate here: dispatch is already centrally
1238        // gated by `arena.is_enabled()` (walking up from the focused
1239        // field through this ZStack to the composite root's
1240        // `enabled_when`) before any handler — including
1241        // `on_key_preview` — runs.
1242        let read_only = self.read_only;
1243        let step_for_key = segment_step.clone();
1244        let stepping_id = ctx.add(ZStack::new().add_child(sized_field_id).on_key_preview(
1245            move |event, ctx_evt| {
1246                if read_only {
1247                    return EventResponse::Ignored;
1248                }
1249                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1250                    return EventResponse::Ignored;
1251                };
1252                let mult = if modifiers.shift() { 10 } else { 1 };
1253                let delta = match key {
1254                    Key::ArrowUp => mult,
1255                    Key::ArrowDown => -mult,
1256                    Key::PageUp => 10 * mult,
1257                    Key::PageDown => -10 * mult,
1258                    _ => return EventResponse::Ignored,
1259                };
1260                step_for_key(delta, ctx_evt);
1261                EventResponse::Handled
1262            },
1263        ));
1264        // Return both the outer stepping wrapper (used for layout) and the
1265        // inner editable field id, so the caller can wire `described_by` onto
1266        // the node that actually carries `Role::{Date,Time}Input`.
1267        (stepping_id, field_id)
1268    }
1269}
1270
1271/// Per-half data passed into `build_field` so the segment-step closure
1272/// can be specialised for date vs time without reaching back into
1273/// `self` through extra clones at every Up/Down keystroke.
1274enum DateTimeHalfKind {
1275    Date {
1276        pattern: Rc<ParsedPattern>,
1277        date_signal: Signal<Option<Date>>,
1278        text_signal: Signal<String>,
1279        min: Option<Date>,
1280        max: Option<Date>,
1281        merge: Rc<dyn Fn(Option<Date>, &mut EventContext)>,
1282    },
1283    Time {
1284        pattern: Rc<ParsedPattern>,
1285        time_signal: Signal<Option<Time>>,
1286        text_signal: Signal<String>,
1287        min: Option<Time>,
1288        max: Option<Time>,
1289        merge: Rc<dyn Fn(Option<Time>, &mut EventContext)>,
1290    },
1291}
1292
1293/// Severity rank for `ValidationFeedback`. Higher = more severe.
1294/// Re-exported via `compose_feedback` for the test module.
1295pub(crate) fn rank(fb: &ValidationFeedback) -> u8 {
1296    match fb {
1297        ValidationFeedback::Invalid { .. } => 3,
1298        ValidationFeedback::Corrected { .. } => 2,
1299        ValidationFeedback::Valid => 1,
1300        ValidationFeedback::Pristine => 0,
1301    }
1302}
1303
1304/// Pick the more severe of two halves. `Invalid > Corrected > Valid >
1305/// Pristine`. Currently only used by the test module — the live
1306/// composition path inlines the same `max-by-rank` merge inside each
1307/// half's feedback effect for clarity.
1308#[cfg(test)]
1309pub(crate) fn compose_feedback(
1310    a: &ValidationFeedback,
1311    b: &ValidationFeedback,
1312) -> ValidationFeedback {
1313    if rank(a) >= rank(b) {
1314        a.clone()
1315    } else {
1316        b.clone()
1317    }
1318}
1319
1320/// Painted middle-dot glyph used as the visual separator between the
1321/// date and time halves. Same stroke convention as `DateRangeEdit`'s
1322/// arrow chevron — sized off the field height.
1323fn middle_dot_icon(size: f32) -> IconWidget {
1324    let mut path = Path::new();
1325    let s = size;
1326    let cx = s * 0.5;
1327    let cy = s * 0.5;
1328    let r = s * 0.10;
1329    // Approximate a small filled circle with two cubic-ish curves via
1330    // four straight-line segments forming a diamond. Tiny enough that
1331    // the diamond reads as a dot at typical glyph sizes.
1332    path.move_to(Point::new(cx, cy - r));
1333    path.line_to(Point::new(cx + r, cy));
1334    path.line_to(Point::new(cx, cy + r));
1335    path.line_to(Point::new(cx - r, cy));
1336    path.close();
1337    IconWidget::from_path(path, size)
1338}