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