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