Skip to main content

teksilo_widgets/
date_edit.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DateEdit` — text input + calendar popover, bound to `Signal<Option<Date>>`.
5//!
6//! A single-line editable date field. The underlying surface is a
7//! `TextInputField` displaying the formatted date; commit on Enter or
8//! blur parses the input against the active pattern, clamps to
9//! `[min_date, max_date]`, and writes the result back. A trailing
10//! calendar-icon button opens a [`Calendar`]
11//! popover anchored below the field for graphical date selection.
12//!
13//! # Behaviour
14//!
15//! - **Value binding**: `Signal<Option<Date>>` is the source of truth.
16//!   External writes re-format the text. `None` shows the placeholder.
17//! - **Pattern**: locale-derived strftime-subset (`%Y-%m-%d`,
18//!   `%m/%d/%Y`, …); override via `format_pattern`.
19//! - **Step keys** (preview-pass on the field): the step is
20//!   *segment-relative* — it moves the field under the caret (year,
21//!   month or day), which is what `QDateTimeEdit` does.
22//!   - Arrow Up / Down → ±1 unit of that segment; Shift+ → ±10.
23//!   - Page Up / Page Down → ±10 units; Shift+ → ±100.
24//!   - `Alt+ArrowDown` (or click the calendar icon) → opens the calendar
25//!     popover; `Alt+ArrowUp` closes it and `F4` toggles it.
26//!   - A chord holding `Ctrl` or `Super` is not the field's and falls
27//!     through to the application.
28//! - **Calendar popover**: dismisses on click-outside or Escape,
29//!   commits on cell click. The request carries no `fade_duration`, so
30//!   it appears and goes without a fade.
31//! - **Min / Max**: clamps on commit and on step. Out-of-range values
32//!   in the popover cell are disabled.
33//!
34//! # Accessibility
35//!
36//! - Container — `Role::DateInput`, `set_value` to ISO selection,
37//!   `set_label` from `.label()` builder, `set_placeholder` when
38//!   value is `None`.
39//! - Calendar trigger button — `Role::Button`, named from its tooltip.
40//!   `set_has_popup(HasPopup::Grid)` and `set_expanded(open)` sit on the
41//!   container node above, not on the button.
42//! - Internally the editing surface remains a `Role::TextInput` for
43//!   AT discoverability (so screen readers know it accepts text); the
44//!   wrapper carries the DateInput role on the outer node.
45//!
46//! # Example
47//!
48//! ```ignore
49//! use teksilo::widgets::{DateEdit, common::datetime::Date};
50//!
51//! let date = ctx.signal(Some(Date::constant(2026, 5, 2)));
52//! ctx.add(
53//!     DateEdit::new(date.clone())
54//!         .min_date(Date::constant(2020, 1, 1))
55//!         .max_date(Date::constant(2030, 12, 31))
56//!         .label(lit!("Birth date")),
57//! );
58//! ```
59//!
60//! ## Touch and pen
61//!
62//! Nothing to declare here, and the reason applies to the whole date/time
63//! family ([`TimeEdit`](crate::time_edit::TimeEdit),
64//! [`DateTimeEdit`](crate::date_time_edit::DateTimeEdit),
65//! [`DateRangeEdit`](crate::date_range_edit::DateRangeEdit)): the composite's
66//! own handler set carries `focus_within` and `on_key_preview` only — no tap,
67//! no drag, no scroll — so its pointer surface is entirely the embedded text
68//! field, the trigger `IconButton` and the popover `Calendar`, each of which
69//! answers for itself. In particular the family does **not** declare
70//! `touch_action(NONE)`: it produces no value from a press position, and
71//! declaring it would stop a finger scrolling a form that contains a date
72//! field.
73
74#[cfg(test)]
75mod tests;
76
77use std::rc::Rc;
78use teksilo_i18n::localized;
79
80use jiff::civil::Weekday;
81use teksilo_canvas::{Path, Point, Rect, SizeProposal};
82use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
83use teksilo_core::accesskit::{Action, HasPopup, Role};
84use teksilo_core::build_context::BuildContext;
85use teksilo_core::event::{EventResponse, Key, WidgetEvent};
86use teksilo_core::overlay::{
87    DismissBehavior, OverlayDismissCallback, OverlayLayer, OverlayPlacement, OverlayRequest,
88};
89use teksilo_core::signal::{Prop, Signal};
90use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
91use teksilo_core::widget_builder::HandlerSet;
92use teksilo_core::widget_id::WidgetId;
93use teksilo_i18n::resolve_message_widget;
94
95use crate::calendar::Calendar;
96use crate::common::datetime::Date;
97use crate::common::datetime::pattern::{
98    ParseTarget, ParsedPattern, ParsedValue, PatternToken, SegmentKind, format_value,
99    mask_for_pattern, parse_value, segment_at_position, step_date_field,
100};
101use crate::common::datetime::types::{YearMonth, today_local};
102use crate::common::range_nav;
103use crate::icon_button::{IconButton, IconButtonSize};
104use crate::primitives::IconWidget;
105use crate::primitives::text_input_field::{ValidationFeedback, ValidationOutcome};
106use crate::text_input::TextInput;
107use teksilo_i18n::LocalizedString;
108
109type OnValueChanged = Rc<dyn Fn(Option<Date>, &mut EventContext)>;
110
111/// How a datetime widget claims horizontal space.
112///
113/// Shared across `DateEdit`, `TimeEdit`, `DateRangeEdit`, and
114/// `DateTimeEdit`. For the two-half widgets the policy applies to
115/// the *trailing* half only — the leading half always sizes to its
116/// mask-derived natural width so the date never reflows when only
117/// the time half changes.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub enum WidthPolicy {
120    /// **Default.** The widget claims its natural width: the mask-derived
121    /// empty template (`__/__/____` for ISO date, `__:__` for 24h time)
122    /// measured in the theme body font plus surrounding chrome.
123    /// The footprint stays fixed as the user types — Int UI form-density
124    /// convention. This is the [`Default`].
125    #[default]
126    Default,
127    /// The widget expands to fill the horizontal space its parent offers,
128    /// instead of capping at the natural mask width. Use inside toolbars,
129    /// inspector panels, or an `Expand::horizontal` column that should
130    /// stretch with the surrounding layout.
131    Fill,
132}
133
134/// How the date editor reacts to out-of-range or partially invalid input.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
136pub enum ValidationBehavior {
137    /// Out-of-range inputs are clamped to the nearest valid value
138    /// (e.g. `12/50/2026` → `12/31/2026`) and announced via `Live::Polite`.
139    /// Matches macOS Calendar and iOS DatePicker. This is the [`Default`].
140    #[default]
141    AutoCorrect,
142    /// Out-of-range inputs are rejected with an inline error message;
143    /// the field's text is left as-typed so the user can correct it.
144    /// The bound value is unchanged until a valid date is committed.
145    /// Matches Excel / Material strict-validation patterns. Use for
146    /// high-precision contexts where silently rounding is unacceptable.
147    Reject,
148}
149
150/// Single-line date input with optional calendar popover. See the
151/// [module docs](self) for the full feature list.
152pub struct DateEdit {
153    value: Signal<Option<Date>>,
154    /// Set by `::required(Signal<Date>)` — the original non-nullable
155    /// upstream that needs to mirror with `value`. Wired via
156    /// `ctx.effect()` in `build()` so the observer handles live with
157    /// the widget rather than being dropped at construction.
158    required_source: Option<Signal<Date>>,
159    min_date: Option<Date>,
160    max_date: Option<Date>,
161    pattern: Option<String>,
162    placeholder: LocalizedString,
163    first_day_of_week: Option<Weekday>,
164    show_calendar_button: bool,
165    calendar_popover_placement: OverlayPlacement,
166    /// Enabled state, static or reactive; forwarded to the arena at
167    /// build time.
168    enabled: Prop<bool>,
169    read_only: bool,
170    /// How parse failures are surfaced. Default `AutoCorrect`.
171    validation_behavior: ValidationBehavior,
172    /// How the field claims horizontal space. Default
173    /// [`WidthPolicy::Default`] — the field sizes to its natural
174    /// mask-derived width and stays put.
175    width_policy: WidthPolicy,
176    label: Option<LocalizedString>,
177    on_value_changed: Option<OnValueChanged>,
178    /// Live feedback signal mirrored from the inner field, owned by
179    /// `DateEdit` so the wrapper's `accessibility()` and the
180    /// `ValidationStrip` below the field both bind to it.
181    feedback: Signal<ValidationFeedback>,
182    /// Live edit text driven by both user typing and programmatic
183    /// re-formatting (mirroring SpinBox's pattern).
184    text_signal: Signal<String>,
185    /// Field-focus tracker; controls whether the value reformat effect
186    /// stomps on user typing.
187    focused: Signal<bool>,
188    /// Whether the calendar popover is currently open. Drives
189    /// `set_expanded` on the trigger.
190    popover_open: Signal<bool>,
191    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
192    /// with the rich / composite slots — every setter clears the other two so
193    /// the last call wins.
194    tooltip_text: Option<LocalizedString>,
195    /// Optional rich tooltip source (registry key or inline content).
196    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
197    /// Optional composite tooltip body (arbitrary widget tree).
198    composite_tooltip_content: Option<Box<dyn Widget>>,
199    // Build state
200    /// Per-call DateEditStyle override. Higher precedence than the
201    /// theme-wide `style_slots.date_edit` slot.
202    style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
203    root_child_id: Option<WidgetId>,
204    calendar_id: Option<WidgetId>,
205}
206
207impl std::fmt::Debug for DateEdit {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("DateEdit")
210            .field("min", &self.min_date)
211            .field("max", &self.max_date)
212            .field("enabled", &self.enabled.get())
213            .finish_non_exhaustive()
214    }
215}
216
217impl DateEdit {
218    /// Construct a date editor bound to a nullable date signal.
219    pub fn new(value: Signal<Option<Date>>) -> Self {
220        Self {
221            value,
222            required_source: None,
223            min_date: None,
224            max_date: None,
225            pattern: None,
226            placeholder: LocalizedString::literal(String::new()),
227            first_day_of_week: None,
228            show_calendar_button: true,
229            calendar_popover_placement: OverlayPlacement::BelowPreferred,
230            enabled: Prop::Static(true),
231            read_only: false,
232            validation_behavior: ValidationBehavior::AutoCorrect,
233            width_policy: WidthPolicy::Default,
234            label: None,
235            on_value_changed: None,
236            feedback: Signal::new(ValidationFeedback::Pristine),
237            text_signal: Signal::new(String::new()),
238            focused: Signal::new(false),
239            popover_open: Signal::new(false),
240            tooltip_text: None,
241            rich_tooltip_source: None,
242            composite_tooltip_content: None,
243            style_override: None,
244            root_child_id: None,
245            calendar_id: None,
246        }
247    }
248
249    /// Per-call style override for the date-edit chrome.
250    pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
251        self.style_override = Some(Rc::new(style));
252        self
253    }
254
255    /// Construct from a non-nullable date signal. Internally backed by
256    /// a `Signal<Option<Date>>` proxy that mirrors the source in both
257    /// directions. The placeholder is normally unused — the proxy is
258    /// initialized to `Some(value.get())`, and only goes `None`
259    /// transiently when a commit clears the text (the mirror then leaves
260    /// the source alone until the next valid commit re-establishes it).
261    pub fn required(value: Signal<Date>) -> Self {
262        let proxy: Signal<Option<Date>> = Signal::new(Some(value.get()));
263        let mut s = Self::new(proxy);
264        s.required_source = Some(value);
265        s
266    }
267
268    /// Clamp the selectable range from below. Dates earlier than `d`
269    /// are clamped up to `d` on commit and on step, and are shown as
270    /// disabled in the calendar popover.
271    pub fn min_date(mut self, d: Date) -> Self {
272        self.min_date = Some(d);
273        self
274    }
275
276    /// Clamp the selectable range from above. Dates later than `d`
277    /// are clamped down to `d` on commit and on step, and are shown as
278    /// disabled in the calendar popover.
279    pub fn max_date(mut self, d: Date) -> Self {
280        self.max_date = Some(d);
281        self
282    }
283
284    /// Override the locale-derived format pattern (strftime subset, see
285    /// `crate::common::datetime::pattern`).
286    pub fn format_pattern(mut self, pat: impl Into<String>) -> Self {
287        self.pattern = Some(pat.into());
288        self
289    }
290
291    /// Text displayed when the bound value is `None`. Defaults to empty
292    /// (no placeholder rendered).
293    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
294        let ls: LocalizedString = text.into();
295        self.placeholder = ls;
296        self
297    }
298
299    /// Override which weekday heads the calendar's column grid.
300    /// Defaults to the locale's convention if not set.
301    pub fn first_day_of_week(mut self, w: Weekday) -> Self {
302        self.first_day_of_week = Some(w);
303        self
304    }
305
306    /// Show or hide the trailing calendar-icon trigger button that opens
307    /// the calendar popover. Default `true`.
308    pub fn show_calendar_button(mut self, show: bool) -> Self {
309        self.show_calendar_button = show;
310        self
311    }
312
313    /// Override where the calendar popover appears relative to the field.
314    /// Default is [`OverlayPlacement::BelowPreferred`].
315    pub fn calendar_popover_placement(mut self, p: OverlayPlacement) -> Self {
316        self.calendar_popover_placement = p;
317        self
318    }
319
320    /// Set the enabled state, statically or reactively. Forwarded to
321    /// the arena at build time.
322    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
323        self.enabled = enabled.into();
324        self
325    }
326
327    /// Make the field read-only: text is selectable and copyable but
328    /// not editable, and step keys are suppressed.
329    pub fn read_only(mut self, read_only: bool) -> Self {
330        self.read_only = read_only;
331        self
332    }
333
334    /// How parse failures are surfaced. Default
335    /// [`ValidationBehavior::AutoCorrect`] (clamp + announce); switch
336    /// to [`ValidationBehavior::Reject`] for strict-validation form
337    /// contexts.
338    pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
339        self.validation_behavior = behavior;
340        self
341    }
342
343    /// How the widget claims horizontal space. Default
344    /// [`WidthPolicy::Default`] — the field sizes to its natural
345    /// mask-derived width. Switch to [`WidthPolicy::Fill`] to make
346    /// the field stretch to fill the parent's offered width
347    /// (toolbar / inspector pattern).
348    pub fn width_policy(mut self, policy: WidthPolicy) -> Self {
349        self.width_policy = policy;
350        self
351    }
352
353    /// Reactive handle on the live validation feedback (mirrored from
354    /// the inner field). Composites that want to render their own
355    /// feedback UI elsewhere can bind to this; the default
356    /// `ValidationStrip` slot below the field uses it internally.
357    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
358        self.feedback.clone()
359    }
360
361    /// Set the accessible label for the field (also shown by any paired
362    /// `FormLayout` label slot). Defaults to the localized "Date" string.
363    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
364        let ls: LocalizedString = label.into();
365        self.label = Some(ls);
366        self
367    }
368
369    /// Register a callback fired on every committed value change with the
370    /// new `Option<Date>` and a live `EventContext`. Fires only on
371    /// user-driven commits (typing + blur, Enter, calendar selection),
372    /// not on external writes to the bound signal.
373    pub fn on_value_changed(
374        mut self,
375        f: impl Fn(Option<Date>, &mut EventContext) + 'static,
376    ) -> Self {
377        self.on_value_changed = Some(Rc::new(f));
378        self
379    }
380
381    /// Return a clone of the bound value signal for external observation.
382    pub fn value(&self) -> Signal<Option<Date>> {
383        self.value.clone()
384    }
385
386    /// Attach a plain single-line tooltip shown after a hover delay.
387    /// Mutually exclusive with [`Self::rich_tooltip`],
388    /// [`Self::rich_tooltip_content`], and [`Self::composite_tooltip`] —
389    /// this call clears those slots.
390    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
391        self.tooltip_text = Some(text.into());
392        self.rich_tooltip_source = None;
393        self.composite_tooltip_content = None;
394        self
395    }
396
397    /// Attach a rich tooltip looked up by registry key. Mutually exclusive
398    /// with [`Self::tooltip`], [`Self::rich_tooltip_content`], and
399    /// [`Self::composite_tooltip`] — this call clears those slots.
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    /// Attach a rich tooltip from inline content. Mutually exclusive with
408    /// [`Self::tooltip`], [`Self::rich_tooltip`], and
409    /// [`Self::composite_tooltip`] — this call clears those slots.
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    /// Attach a composite tooltip whose body is an arbitrary widget tree.
418    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
419    /// and [`Self::rich_tooltip_content`] — this call clears those slots.
420    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
421        self.composite_tooltip_content = Some(Box::new(content));
422        self.tooltip_text = None;
423        self.rich_tooltip_source = None;
424        self
425    }
426}
427
428impl Widget for DateEdit {
429    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
430        // Wire required-source mirror via ctx.effect so the observer
431        // handles live with the widget rather than being dropped at
432        // construction. Effects auto-clean on rebuild.
433        if let Some(src) = self.required_source.clone() {
434            // Source → proxy.
435            {
436                let proxy = self.value.clone();
437                ctx.effect(&src, move |new| {
438                    if proxy.get() != Some(*new) {
439                        proxy.set(Some(*new));
440                    }
441                });
442            }
443            // Proxy → source. The proxy can hold `None` transiently
444            // (parse failure → cleared text); ignore that and let the
445            // next valid commit re-establish the value. The required
446            // contract is "always have a value upstream", which the
447            // initial seed in `::required` guarantees.
448            {
449                let src_clone = src;
450                ctx.effect(&self.value, move |v| {
451                    if let Some(d) = v
452                        && src_clone.get() != *d
453                    {
454                        src_clone.set(*d);
455                    }
456                });
457            }
458        }
459
460        let theme = ctx.theme_signal().get();
461        use crate::styles::recipe_date_edit_style as de;
462        let _ = &theme;
463        let self_id = ctx.self_id();
464        // Forward the enabled state into the arena; see IconButton.
465        ctx.enabled_when(self_id, self.enabled.clone());
466        let enabled = self.enabled.get();
467        let read_only = self.read_only;
468
469        // A locale switch must re-derive the date pattern: it is read from
470        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
471        // only calls `mark_all_dirty` (layout + paint), which never re-runs
472        // `build()`. Without this binding the widget keeps rendering with
473        // the pattern of whatever locale was active when it was first
474        // built. Bound at `Rebuild` for the same reason `Calendar` binds
475        // the text scale there — the value is a build-time constant, so a
476        // relayout cannot pick it up.
477        ctx.locale_signal().bind_to(
478            ctx.self_id(),
479            ctx.binding_registry(),
480            teksilo_core::binding::BindingLevel::Rebuild,
481        );
482
483        // Resolve pattern: explicit override → locale default.
484        let pattern_string = self.pattern.clone().unwrap_or_else(|| {
485            let tag = ctx.locale_signal().get().unwrap_or_default();
486            crate::common::datetime::format_pattern_for_locale(&tag).to_string()
487        });
488        let parsed_pattern = ParsedPattern::parse(&pattern_string)
489            .unwrap_or_else(|_| ParsedPattern::parse("%Y-%m-%d").unwrap());
490        let pattern_rc = Rc::new(parsed_pattern);
491        let placeholder = self.placeholder.clone();
492        let min = self.min_date;
493        let max = self.max_date;
494        let on_value_changed = self.on_value_changed.clone();
495
496        // Seed text from current value.
497        {
498            let init = match self.value.get() {
499                Some(d) => format_value(&pattern_rc, Some(d), None),
500                None => String::new(),
501            };
502            self.text_signal.set(init);
503        }
504
505        // External writes → reformat (skip while focused).
506        {
507            let text_signal = self.text_signal.clone();
508            let focused = self.focused.clone();
509            let pattern = pattern_rc.clone();
510            ctx.effect(&self.value, move |new_value| {
511                if focused.get() {
512                    return;
513                }
514                let formatted = match new_value {
515                    Some(d) => format_value(&pattern, Some(*d), None),
516                    None => String::new(),
517                };
518                if text_signal.get() != formatted {
519                    text_signal.set(formatted);
520                }
521            });
522        }
523
524        // ── Validator ─────────────────────────────────────────
525        // Pure classification: given raw text, return one of three
526        // outcomes. The field's wrapper writes feedback signal +
527        // re-formats text on `Corrected`. The on_blur callback
528        // (chained AFTER the validator) re-parses the (now-corrected)
529        // text and updates the bound value signal + fires the user
530        // callback with EventContext.
531        let validation_behavior = self.validation_behavior;
532        let validator: crate::primitives::text_input_field::ValidatorFn = {
533            let pattern = pattern_rc.clone();
534            Rc::new(move |raw: &str| -> ValidationOutcome {
535                let trimmed = raw.trim();
536                if trimmed.is_empty() {
537                    // Empty is valid (clears value to None on commit).
538                    return ValidationOutcome::Valid;
539                }
540                // 1. Try strict parse + reformat-compare to detect
541                //    lenient-fill normalization (e.g., "2026" →
542                //    "2026-01-01", or "2026-5" → "2026-05-01").
543                if let Some(ParsedValue::Date(d)) =
544                    parse_value(&pattern, trimmed, ParseTarget::DateOnly)
545                {
546                    let clamped = clamp_date(d, min, max);
547                    let formatted = format_value(&pattern, Some(clamped), None);
548                    if formatted == trimmed && clamped == d {
549                        return ValidationOutcome::Valid;
550                    }
551                    return ValidationOutcome::Corrected {
552                        corrected: formatted.clone(),
553                        message: localized(move || {
554                            resolve_message_widget(
555                                "validation-corrected-to",
556                                &[("value", formatted.clone().into())],
557                            )
558                        }),
559                    };
560                }
561                // 2. Strict parse failed. Try clamp-recovery: extract
562                //    each segment value, clamp out-of-range values to
563                //    their valid range, and re-construct.
564                if validation_behavior == ValidationBehavior::AutoCorrect
565                    && let Some((corrected, msg)) = try_clamp_recovery(&pattern, trimmed, min, max)
566                {
567                    return ValidationOutcome::Corrected {
568                        corrected,
569                        message: msg,
570                    };
571                }
572                // 3. Truly unparseable. Reject.
573                ValidationOutcome::Invalid {
574                    message: localized(move || {
575                        resolve_message_widget("date-edit-validation-not-a-date", &[])
576                    }),
577                }
578            })
579        };
580
581        // Commit-side effect: when the inner field's wrapper writes
582        // a Corrected outcome, the text_signal already holds the
583        // formatted-corrected text. Re-parse and sync the bound
584        // value + fire on_value_changed via the chained on_blur
585        // callback below.
586        //
587        // For Invalid: leave the typed text in the field so the user
588        // can fix it; do NOT silently revert (the user's complaint
589        // that triggered this whole feature). The bound value stays
590        // unchanged.
591        // Returns whether the text committed — `false` while the validator is
592        // unhappy, and `false` for a string the pattern cannot read. Only the
593        // assistive-technology write reads it; `Enter` and blur discard it.
594        let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
595            let value_signal = self.value.clone();
596            let text_signal = self.text_signal.clone();
597            let feedback_signal = self.feedback.clone();
598            let pattern = pattern_rc.clone();
599            let on_value_changed = on_value_changed.clone();
600            Rc::new(move |ctx_evt: &mut EventContext| {
601                let fb = feedback_signal.get();
602                if matches!(fb, ValidationFeedback::Invalid { .. }) {
603                    // Don't touch value or reformat text; let the
604                    // user fix what they typed.
605                    return false;
606                }
607                let raw = text_signal.get();
608                let trimmed = raw.trim();
609                let (new_value, accepted): (Option<Date>, bool) = if trimmed.is_empty() {
610                    (None, true)
611                } else {
612                    match parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
613                        Some(ParsedValue::Date(d)) => (Some(clamp_date(d, min, max)), true),
614                        _ => (value_signal.get(), false),
615                    }
616                };
617                if value_signal.get() != new_value {
618                    value_signal.set(new_value);
619                    if let Some(cb) = on_value_changed.as_ref() {
620                        cb(new_value, ctx_evt);
621                    }
622                }
623                accepted
624            })
625        };
626
627        // No standalone day-step closure — segment-aware stepping is
628        // installed inside the on_key_preview self handler below
629        // (replaces the pre-segment ±day stepping that used to live
630        // here).
631
632        // ── Calendar popover (pre-built dormant) ──────────────
633        // Built before the TextInput composite so the trailing-slot
634        // trigger button can capture the calendar's id.
635        let calendar_id_opt = if self.show_calendar_button {
636            // Bridge signal: the calendar binds to a parallel
637            // `Signal<Option<Date>>` so its internal cell-render +
638            // arrow-key state can mutate freely; the popover commit
639            // path writes the final selection into our `value`. We
640            // keep the bridge in sync with external `value` changes
641            // via `ctx.effect` (NOT `observe()` — observers return
642            // RAII handles that get dropped at construction; effects
643            // live with the widget).
644            let calendar_temp: Signal<Option<Date>> = Signal::new(self.value.get());
645            {
646                let temp = calendar_temp.clone();
647                ctx.effect(&self.value, move |new_value| {
648                    if temp.get() != *new_value {
649                        temp.set(*new_value);
650                    }
651                });
652            }
653            let popover_open = self.popover_open.clone();
654            let value_for_cal = self.value.clone();
655            let text_signal_for_cal = self.text_signal.clone();
656            let pattern_for_cal = pattern_rc.clone();
657            let on_value_changed_for_cal = on_value_changed.clone();
658            let return_focus_to = ctx.self_id();
659            let mut calendar =
660                Calendar::single(calendar_temp.clone()).on_activate(move |d, ctx_evt| {
661                    let clamped = clamp_date(d, min, max);
662                    value_for_cal.set(Some(clamped));
663                    text_signal_for_cal.set(format_value(&pattern_for_cal, Some(clamped), None));
664                    if let Some(cb) = on_value_changed_for_cal.as_ref() {
665                        cb(Some(clamped), ctx_evt);
666                    }
667                    popover_open.set(false);
668                    ctx_evt.dismiss_self_overlay_chain();
669                    // Return focus to the DateEdit so keyboard users
670                    // are back at the trigger after committing —
671                    // matches the open path's `request_focus(calendar_id)`
672                    // and keeps the focus pointer on a sensible widget
673                    // (Tab from here lands wherever Tab would have
674                    // gone next, not at the document root).
675                    ctx_evt.request_focus(return_focus_to);
676                    ctx_evt.request_frame();
677                });
678            if let Some(min) = min {
679                calendar = calendar.min_date(min);
680            }
681            if let Some(max) = max {
682                calendar = calendar.max_date(max);
683            }
684            if let Some(fdow) = self.first_day_of_week {
685                calendar = calendar.first_day_of_week(fdow);
686            }
687            // Detached, not a child: the popup must not wake or paint with the
688            // field. `add_detached` records the ownership edge anyway, so the
689            // calendar dies with this widget and each rebuild reaps the
690            // previous one — a bare `ctx.add` stranded a ~200-widget calendar
691            // in the arena per rebuild.
692            // Built the first time the popup is opened, not on every rebuild of the
693            // field. See `teksilo_core::deferred_subtree::DeferredSubtree`.
694            let calendar_id = ctx.add_detached_deferred(self.popover_open.clone(), calendar);
695            ctx.set_dormant(calendar_id);
696            Some(calendar_id)
697        } else {
698            None
699        };
700        self.calendar_id = calendar_id_opt;
701
702        // ── Calendar trigger button (built as a value, dropped into
703        //    the TextInput's trailing slot) ──────────────────────
704        // Same Int UI `IconButton` (in embedded mode) the other
705        // datetime widgets (DateRangeEdit, DateTimeEdit) use, so the
706        // visual treatment — hover/pressed background, icon size,
707        // focus halo — stays consistent across the family.
708        //
709        // `toggle_calendar` is the one definition of "open or close the
710        // calendar", shared by the trailing icon button below and by the
711        // `Alt+ArrowDown` / `F4` chords in the key handler. `None` when
712        // `show_calendar_button(false)` built no calendar at all — the chords
713        // then fall through rather than pretending.
714        let mut toggle_calendar: Option<Rc<dyn Fn(&mut EventContext)>> = None;
715
716        let trigger_widget_opt: Option<IconButton> = if self.show_calendar_button {
717            let popover_open = self.popover_open.clone();
718            let calendar_id = calendar_id_opt.expect("calendar built when button enabled");
719            let placement = self.calendar_popover_placement.clone();
720            let self_ref = ctx.self_id();
721            let dismiss_cb: OverlayDismissCallback = {
722                let popover_open = popover_open.clone();
723                Rc::new(move |_, _| {
724                    popover_open.set(false);
725                })
726            };
727            let toggle: Rc<dyn Fn(&mut EventContext)> = Rc::new({
728                let popover_open = popover_open.clone();
729                move |ctx_evt: &mut EventContext| {
730                    if popover_open.get() {
731                        popover_open.set(false);
732                        ctx_evt.dismiss_all_except_hosts();
733                    } else {
734                        popover_open.set(true);
735                        // Build the popup if this is its first open, before the overlay
736                        // below is measured against it and focus moves into it.
737                        ctx_evt.materialize_now(calendar_id);
738                        ctx_evt.activate(calendar_id);
739                        ctx_evt.show_overlay(OverlayRequest {
740                            content_id: calendar_id,
741                            anchor: self_ref,
742                            placement: placement.clone(),
743                            dismiss: DismissBehavior::EscapeOrClickOutside,
744                            layer: OverlayLayer::InTree,
745                            parent_overlay: None,
746                            on_dismiss: Some(dismiss_cb.clone()),
747                            fade_duration: None,
748                        });
749                        // Move focus into the calendar so arrow keys
750                        // navigate cells immediately — standard date-
751                        // picker UX (macOS Calendar, JetBrains, etc.).
752                        // Without this the user must Tab through
753                        // unrelated widgets first.
754                        ctx_evt.request_focus(calendar_id);
755                    }
756                }
757            });
758            toggle_calendar = Some(toggle.clone());
759            Some(
760                IconButton::new(calendar_glyph_icon(de::CALENDAR_ICON_SIZE))
761                    .embedded()
762                    .size(IconButtonSize::Default)
763                    .enabled(enabled && !read_only)
764                    .tooltip(localized(move || {
765                        resolve_message_widget("date-edit-trigger-tooltip", &[])
766                    }))
767                    .on_activate_fn(move |ctx_evt: &mut EventContext| toggle(ctx_evt)),
768            )
769        } else {
770            None
771        };
772
773        // ── TextInput composite ───────────────────────────────
774        // Drops the date-shaped editing surface into the same frame
775        // every TextInput uses (border, padding, validation strip,
776        // focus border) and parks the calendar trigger in its
777        // trailing slot — flush against the field's right edge with
778        // no manual divider, matching Int UI's embedded IconButton convention.
779        let pattern_for_filter = pattern_rc.clone();
780        let mask_string = mask_for_pattern(&pattern_rc);
781        let mut text_input = TextInput::new(self.text_signal.clone())
782            .placeholder(placeholder.clone())
783            // An assistive-technology `SetValue` on the inner text node is a
784            // finished edit, not a keystroke: push the string the technology
785            // set and run the same commit `Enter` runs, or the typed value
786            // stays stale behind a display nothing ever parses.
787            .on_access_set_value({
788                let text_signal = self.text_signal.clone();
789                let commit = commit.clone();
790                // The commit's verdict is the technology's answer: an
791                // unparseable string leaves the value where it was, and
792                // saying `Handled` there would report a write that never
793                // landed.
794                move |text: &str, ctx: &mut EventContext| {
795                    text_signal.set(text.to_string());
796                    commit(ctx)
797                }
798            })
799            .enabled(enabled)
800            .read_only(read_only)
801            .input_mask(mask_string)
802            .validator({
803                let v = validator.clone();
804                move |s| (v)(s)
805            })
806            .char_filter(move |c: char| {
807                if c.is_ascii_digit() || c == '-' || c == ' ' {
808                    return true;
809                }
810                for tok in &pattern_for_filter.tokens {
811                    if let PatternToken::Literal(s) = tok
812                        && s.chars().any(|x| x == c)
813                    {
814                        return true;
815                    }
816                }
817                false
818            })
819            .on_submit_fn({
820                let commit = commit.clone();
821                move |ctx_evt| {
822                    commit(ctx_evt);
823                }
824            })
825            .on_blur_fn({
826                let commit = commit.clone();
827                move |ctx_evt| {
828                    commit(ctx_evt);
829                }
830            });
831        // NB (audit G9): the label is intentionally NOT forwarded to the inner
832        // TextInput. DateEdit's own accessibility() node (Role::DateInput)
833        // already carries the name; naming the inner TextInput too would both
834        // double-label AND give its GenericContainer semantic content, which
835        // stops the AT walker from dropping it as a presentational node — the
836        // exact cause of the redundant middle node. With no name the container
837        // is content-free and collapses, leaving the 2-node tree
838        // DateEdit(DateInput) -> TextInputField(TextInput + character runs),
839        // matching the SpinBox shape.
840        if let Some(trigger) = trigger_widget_opt {
841            text_input = text_input.trailing_slot(trigger);
842        }
843
844        // Capture caret signal AND a caret setter BEFORE moving the
845        // composite into the tree. The setter is a no-op until
846        // `build()` populates the slot; segment_step uses it to
847        // restore the caret AFTER rewriting text.
848        let caret_for_step = text_input.caret_position();
849        let caret_setter_for_step = text_input.caret_setter();
850
851        // Mirror the inner field's published feedback into our own
852        // signal so the commit closure (which short-circuits on
853        // Invalid) reads the live state. The TextInput composite also
854        // wires this internally to its ValidationStrip.
855        {
856            let inner_feedback = text_input.validation_feedback_signal();
857            let outer_feedback = self.feedback.clone();
858            ctx.effect(&inner_feedback, move |fb| {
859                if outer_feedback.get() != *fb {
860                    outer_feedback.set(fb.clone());
861                }
862            });
863        }
864
865        // Apply width policy. `Default` adds nothing — the field
866        // reports its natural mask-derived width via TextInputField.
867        // `Fill` wraps in an intrinsic-respecting Expand so the
868        // composite stretches to its parent's offered width while
869        // still reporting the natural width when unconstrained
870        // (matches SpinBox's `.fill_width()` semantics).
871        let body_id = match self.width_policy {
872            WidthPolicy::Default => ctx.add(text_input),
873            WidthPolicy::Fill => {
874                let inner_id = ctx.add(text_input);
875                ctx.add(
876                    crate::primitives::Expand::horizontal()
877                        .respect_intrinsic()
878                        .child(inner_id),
879                )
880            }
881        };
882        // Delegate any final wrapping to the active DateEditStyle.
883        let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
884            &self.style_override,
885            ctx,
886        );
887        let cfg = teksilo_core::styles::DateEditStyleConfig { body: body_id };
888        let root_id = style.make_body(&cfg, ctx);
889        self.root_child_id = Some(root_id);
890
891        // ── Tooltip attachment ─────────────────────────────────
892        // Anchored on the visible trigger root (not the calendar overlay).
893        if let Some(content) = self.composite_tooltip_content.take() {
894            let delay = ctx.theme().motion.tooltip_delay_heavy;
895            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
896        } else if let Some(source) = self.rich_tooltip_source.clone() {
897            let delay = ctx.theme().motion.tooltip_delay;
898            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
899        } else if let Some(text) = self.tooltip_text.clone() {
900            let delay = ctx.theme().motion.tooltip_delay;
901            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
902        }
903
904        // ── Segment-stepping helper — captured by the on_key_preview
905        // self handler below. Reads live caret position, looks up the
906        // segment under the caret, and applies a single field step
907        // (year / month / day).
908        let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = {
909            let pattern_for_step = pattern_rc.clone();
910            let value_for_step = self.value.clone();
911            let text_for_step = self.text_signal.clone();
912            let on_changed_for_step = on_value_changed.clone();
913            let min_for_step = self.min_date;
914            let max_for_step = self.max_date;
915            let caret_for_step = caret_for_step.clone();
916            let caret_setter = caret_setter_for_step.clone();
917            Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
918                let caret = caret_for_step.get();
919                let Some((_, _, kind)) = segment_at_position(&pattern_for_step, caret) else {
920                    return;
921                };
922                let current = value_for_step.get().unwrap_or_else(today_local);
923                let stepped = step_date_field(current, kind, delta);
924                let clamped = clamp_date(stepped, min_for_step, max_for_step);
925                value_for_step.set(Some(clamped));
926                text_for_step.set(format_value(&pattern_for_step, Some(clamped), None));
927                // Restore the caret to where it was — `text_signal.set`
928                // → field text effect → `cursor.insert_text` parked the
929                // caret at the document end. Without this restore the
930                // user has to re-click the segment between every Up/Down.
931                caret_setter(caret);
932                if let Some(cb) = on_changed_for_step.as_ref() {
933                    cb(Some(clamped), ctx_evt);
934                }
935                ctx_evt.request_frame();
936            })
937        };
938
939        // ── Self handlers: focus_within + segment-step keys ────
940        // `on_key_preview` on SELF (DateEdit, an actual ancestor of
941        // the inner field) claims ArrowUp/ArrowDown/PageUp/PageDown
942        // BEFORE the focused field's `on_key` runs. The step targets
943        // the segment under the caret (year/month/day) — Qt-style
944        // segment-stepping. Shift multiplies the unit step by 10 so
945        // power users can sweep faster (e.g. ±10 years on the year
946        // segment).
947        let step_for_key = segment_step.clone();
948        let toggle_for_key = toggle_calendar.clone();
949        let popover_open_for_key = self.popover_open.clone();
950        let handlers = HandlerSet::new()
951            .focus_within(self.focused.clone())
952            .on_key_preview(move |event, ctx_evt| {
953                if !enabled || read_only {
954                    return EventResponse::Ignored;
955                }
956                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
957                    return EventResponse::Ignored;
958                };
959
960                // `Alt+ArrowDown` opens the calendar, `Alt+ArrowUp` closes it
961                // and `F4` toggles — the Win32 `DateTimePicker` chords, and
962                // what this module's documentation has promised since it was
963                // written. They must be claimed *before* the segment-step match
964                // below, which reads only `Shift`, so `Alt+ArrowDown` used to
965                // step the date back one day instead of opening anything.
966                //
967                // `Alt+ArrowUp` only reaches here while focus is still in the
968                // field: the calendar is added detached, so it is not an arena
969                // child of this widget and a key pressed inside it never
970                // previews through. Escape remains the close from in there.
971                // The platform drop-down chords, from the one table `ComboBox`
972                // and `PopoverWidget` read.
973                if let Some(toggle) = toggle_for_key.as_ref() {
974                    match range_nav::disclosure_chord(*key, *modifiers) {
975                        Some(range_nav::DisclosureChord::Open) => {
976                            if !popover_open_for_key.get() {
977                                toggle(ctx_evt);
978                            }
979                            return EventResponse::Handled;
980                        }
981                        Some(range_nav::DisclosureChord::Close) => {
982                            if popover_open_for_key.get() {
983                                toggle(ctx_evt);
984                                return EventResponse::Handled;
985                            }
986                            return EventResponse::Ignored;
987                        }
988                        Some(range_nav::DisclosureChord::Toggle) => {
989                            toggle(ctx_evt);
990                            return EventResponse::Handled;
991                        }
992                        None => {}
993                    }
994                }
995
996                // Any other accelerator-modified chord is not ours. The segment
997                // stepper reads `Shift` alone, so `Ctrl+ArrowUp` used to step
998                // the date and swallow the chord on the way.
999                if range_nav::is_accelerator_chord(*modifiers) {
1000                    return EventResponse::Ignored;
1001                }
1002
1003                let mult = if modifiers.shift() { 10 } else { 1 };
1004                let delta = match key {
1005                    Key::ArrowUp => mult,
1006                    Key::ArrowDown => -mult,
1007                    Key::PageUp => 10 * mult,
1008                    Key::PageDown => -10 * mult,
1009                    _ => return EventResponse::Ignored,
1010                };
1011                step_for_key(delta, ctx_evt);
1012                EventResponse::Handled
1013            });
1014        ctx.apply_self_handlers(handlers);
1015
1016        // Bind reactive sources at AccessibilityOnly so the wrapper's
1017        // AT node refreshes its `value` and `set_expanded` whenever
1018        // the underlying signals change. Without these, the
1019        // wrapper's accessibility() never re-runs after a value
1020        // change and AT users hear stale data.
1021        let self_id = ctx.self_id();
1022        let registry = ctx.binding_registry();
1023        self.value.bind_to(
1024            self_id,
1025            registry,
1026            teksilo_core::binding::BindingLevel::AccessibilityOnly,
1027        );
1028        self.popover_open.bind_to(
1029            self_id,
1030            registry,
1031            teksilo_core::binding::BindingLevel::AccessibilityOnly,
1032        );
1033
1034        vec![root_id]
1035    }
1036
1037    fn layout_response(
1038        &self,
1039        proposal: SizeProposal,
1040        ctx: &LayoutContext,
1041    ) -> teksilo_core::widget::LayoutResponse {
1042        // Forward the full LayoutResponse — including flex — from the
1043        // child. When `WidthPolicy::Fill` is active, the inner Expand
1044        // wrapper reports flex=1; without forwarding it here, parent
1045        // HStacks see flex=0 and the field never grows.
1046        match self.root_child_id {
1047            Some(id) => ctx
1048                .child_layout_response(id, proposal)
1049                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
1050            None => proposal.resolve(0.0, 0.0).into(),
1051        }
1052    }
1053
1054    fn place_children(
1055        &self,
1056        bounds: Rect,
1057        _proposal: SizeProposal,
1058        children: &mut [WidgetPlacement],
1059        _ctx: &LayoutContext,
1060    ) {
1061        for child in children.iter_mut() {
1062            child.origin = bounds.origin();
1063            child.size = bounds.size();
1064        }
1065    }
1066
1067    fn children(&self) -> Vec<WidgetId> {
1068        self.root_child_id.into_iter().collect()
1069    }
1070
1071    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1072        builder.set_role(Role::DateInput);
1073        if let Some(ref label) = self.label {
1074            builder.set_name(label.resolve_now());
1075        } else {
1076            builder.set_name(resolve_message_widget("date-edit-name", &[]));
1077        }
1078        match self.value.get() {
1079            Some(d) => {
1080                builder.set_value(format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day()));
1081            }
1082            None => {
1083                if !self.placeholder.resolve_now().is_empty() {
1084                    builder.set_placeholder(self.placeholder.resolve_now());
1085                } else {
1086                    builder.set_placeholder(resolve_message_widget("date-edit-placeholder", &[]));
1087                }
1088            }
1089        }
1090        // Framework a11y walker sets `set_disabled` from arena state.
1091        if self.read_only {
1092            builder.set_read_only();
1093        }
1094        builder.add_action(Action::Focus);
1095        // SetValue isn't advertised on this DateInput node because the inner
1096        // TextInputField (Role::TextInput) handles text entry via its own
1097        // TextInputField semantics; routing through both nodes would
1098        // double-process AT requests. The intermediate TextInput
1099        // GenericContainer is dropped by the presentational-node collapse
1100        // (its label is no longer forwarded — see build()), so the AT tree is
1101        // exactly DateInput -> TextInput(editable) with its character runs.
1102        builder.set_has_popup(HasPopup::Grid);
1103        builder.set_expanded(self.popover_open.get());
1104        // Wire popup-controlled relationship when the calendar
1105        // exists. Pointing only when open caused stale ids on the
1106        // first frame after open; safe to point when closed too —
1107        // the calendar widget remains in the arena (dormant) and
1108        // its NodeId is valid.
1109        if let Some(cal_id) = self.calendar_id {
1110            builder.push_controlled(widget_id_to_node_id(cal_id));
1111        }
1112    }
1113}
1114
1115pub(crate) fn calendar_glyph_icon(size: f32) -> IconWidget {
1116    let mut path = Path::new();
1117    let s = size;
1118    // Outer rounded rectangle suggesting a calendar.
1119    let m = s * 0.1;
1120    path.move_to(Point::new(m, s * 0.25));
1121    path.line_to(Point::new(s - m, s * 0.25));
1122    path.line_to(Point::new(s - m, s - m));
1123    path.line_to(Point::new(m, s - m));
1124    path.close();
1125    // Top binding stripe.
1126    path.move_to(Point::new(m, s * 0.25));
1127    path.line_to(Point::new(s - m, s * 0.25));
1128    path.line_to(Point::new(s - m, s * 0.4));
1129    path.line_to(Point::new(m, s * 0.4));
1130    path.close();
1131    // Two binding rings.
1132    let ring_y_top = s * 0.1;
1133    let ring_y_bot = s * 0.3;
1134    let ring_w = s * 0.06;
1135    let ring1_x = s * 0.25;
1136    let ring2_x = s * 0.65;
1137    path.move_to(Point::new(ring1_x, ring_y_top));
1138    path.line_to(Point::new(ring1_x + ring_w, ring_y_top));
1139    path.line_to(Point::new(ring1_x + ring_w, ring_y_bot));
1140    path.line_to(Point::new(ring1_x, ring_y_bot));
1141    path.close();
1142    path.move_to(Point::new(ring2_x, ring_y_top));
1143    path.line_to(Point::new(ring2_x + ring_w, ring_y_top));
1144    path.line_to(Point::new(ring2_x + ring_w, ring_y_bot));
1145    path.line_to(Point::new(ring2_x, ring_y_bot));
1146    path.close();
1147    IconWidget::from_path(path, size)
1148}
1149
1150pub(crate) fn clamp_date(d: Date, min: Option<Date>, max: Option<Date>) -> Date {
1151    let d = match min {
1152        Some(min) if d < min => min,
1153        _ => d,
1154    };
1155    match max {
1156        Some(max) if d > max => max,
1157        _ => d,
1158    }
1159}
1160
1161/// Build a date-validator closure suitable for plugging into
1162/// `TextInputField::validator(...)`. Encapsulates the strict-parse →
1163/// clamp-recovery → reject pipeline that `DateEdit` itself uses,
1164/// so other widgets composing a `TextInputField` over a date pattern
1165/// (e.g. `DateRangeEdit`'s start / end halves) can reuse the same
1166/// validation behaviour without duplicating ~50 lines.
1167///
1168/// `pattern` and `behavior` are captured by value; `min`/`max` clamp
1169/// the parsed date when present.
1170pub(crate) fn build_date_validator(
1171    pattern: Rc<ParsedPattern>,
1172    min: Option<Date>,
1173    max: Option<Date>,
1174    behavior: ValidationBehavior,
1175) -> crate::primitives::text_input_field::ValidatorFn {
1176    Rc::new(move |raw: &str| -> ValidationOutcome {
1177        let trimmed = raw.trim();
1178        if trimmed.is_empty() {
1179            return ValidationOutcome::Valid;
1180        }
1181        if let Some(ParsedValue::Date(d)) = parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
1182            let clamped = clamp_date(d, min, max);
1183            let formatted = format_value(&pattern, Some(clamped), None);
1184            if formatted == trimmed && clamped == d {
1185                return ValidationOutcome::Valid;
1186            }
1187            return ValidationOutcome::Corrected {
1188                corrected: formatted.clone(),
1189                message: localized(move || {
1190                    resolve_message_widget(
1191                        "validation-corrected-to",
1192                        &[("value", formatted.clone().into())],
1193                    )
1194                }),
1195            };
1196        }
1197        if behavior == ValidationBehavior::AutoCorrect
1198            && let Some((corrected, msg)) = try_clamp_recovery(&pattern, trimmed, min, max)
1199        {
1200            return ValidationOutcome::Corrected {
1201                corrected,
1202                message: msg,
1203            };
1204        }
1205        ValidationOutcome::Invalid {
1206            message: localized(move || {
1207                resolve_message_widget("date-edit-validation-not-a-date", &[])
1208            }),
1209        }
1210    })
1211}
1212
1213/// AutoCorrect recovery: extract per-segment integer values from the
1214/// raw input by walking the pattern, clamp each to its valid range
1215/// (year as-is within jiff's bounds; month → 1..=12; day → 1..=
1216/// days_in_month for the resulting year/month), and re-construct.
1217///
1218/// Returns `Some((formatted, message))` on successful recovery,
1219/// `None` if the input is too malformed (e.g., contains non-digits at
1220/// digit positions or doesn't have enough segments).
1221///
1222/// Examples (pattern `%d/%m/%Y`):
1223/// - `"12/50/2026"` → `Some(("12/12/2026", "Auto-corrected: month 50 → 12"))`
1224///   (month clamped to its max 12)
1225/// - `"31/2/2024"` → `Some(("29/02/2024", "Auto-corrected: day 31 → 29 (last day of February)"))`
1226///   (day clamped to month length)
1227/// - `"abc"` → `None`
1228pub(crate) fn try_clamp_recovery(
1229    pattern: &ParsedPattern,
1230    raw: &str,
1231    min: Option<Date>,
1232    max: Option<Date>,
1233) -> Option<(String, LocalizedString)> {
1234    // Walk the pattern; for each digit segment, take whatever digit
1235    // run starts at the current cursor position. For literal tokens,
1236    // optionally consume the literal (lenient — same logic as
1237    // parse_value's literal handling).
1238    let mut cursor = raw;
1239    let mut year: Option<i16> = None;
1240    let mut month: Option<i8> = None;
1241    let mut day: Option<i8> = None;
1242    let mut clamp_notes: Vec<LocalizedString> = Vec::new();
1243
1244    for token in &pattern.tokens {
1245        if cursor.is_empty() {
1246            break;
1247        }
1248        match token {
1249            PatternToken::Literal(lit) => {
1250                if let Some(rest) = cursor.strip_prefix(lit.as_str()) {
1251                    cursor = rest;
1252                } else if lit.starts_with(cursor) {
1253                    cursor = "";
1254                } else {
1255                    // Literal doesn't match → can't recover, give up.
1256                    return None;
1257                }
1258            }
1259            PatternToken::Segment(kind) => {
1260                let max_d = kind.max_digits();
1261                if max_d == 0 {
1262                    continue; // Period segments not handled here
1263                }
1264                let mut end = 0usize;
1265                for (i, ch) in cursor.char_indices() {
1266                    if ch.is_ascii_digit() && end < max_d {
1267                        end = i + ch.len_utf8();
1268                    } else {
1269                        break;
1270                    }
1271                }
1272                if end == 0 {
1273                    // No digits where we expected them; bail.
1274                    return None;
1275                }
1276                let digits = &cursor[..end];
1277                cursor = &cursor[end..];
1278                let raw_v: i32 = digits.parse().ok()?;
1279                let (lo, hi) = kind.value_range().unwrap_or((i32::MIN, i32::MAX));
1280                let clamped = raw_v.clamp(lo, hi);
1281                if clamped != raw_v {
1282                    let segment_key = match kind {
1283                        SegmentKind::Year => "validation-segment-year",
1284                        SegmentKind::Month | SegmentKind::MonthShort => "validation-segment-month",
1285                        SegmentKind::Day | SegmentKind::DayShort => "validation-segment-day",
1286                        _ => "validation-segment-value",
1287                    };
1288                    let segment_label = resolve_message_widget(segment_key, &[]);
1289                    clamp_notes.push(localized(move || {
1290                        resolve_message_widget(
1291                            "validation-segment-clamped",
1292                            &[
1293                                ("segment", segment_label.clone().into()),
1294                                ("raw", (raw_v as i64).into()),
1295                                ("clamped", (clamped as i64).into()),
1296                            ],
1297                        )
1298                    }));
1299                }
1300                match kind {
1301                    SegmentKind::Year => year = Some(clamped as i16),
1302                    SegmentKind::Month | SegmentKind::MonthShort => month = Some(clamped as i8),
1303                    SegmentKind::Day | SegmentKind::DayShort => day = Some(clamped as i8),
1304                    _ => {}
1305                }
1306            }
1307        }
1308    }
1309
1310    let y = year?;
1311    let m = month.unwrap_or(1);
1312    // Day: clamp to days-in-month for the resolved (y, m). This
1313    // catches "31 February" → "28/29 February" (depends on leap).
1314    let last_day = YearMonth::new(y, m).last_day().day();
1315    let raw_day = day.unwrap_or(1);
1316    let d = raw_day.min(last_day).max(1);
1317    if d != raw_day {
1318        clamp_notes.push(localized(move || {
1319            resolve_message_widget(
1320                "validation-day-clamped-to-month",
1321                &[
1322                    ("raw", (raw_day as i64).into()),
1323                    ("clamped", (d as i64).into()),
1324                ],
1325            )
1326        }));
1327    }
1328
1329    let date = Date::new(y, m, d).ok()?;
1330    let final_date = clamp_date(date, min, max);
1331    if final_date != date {
1332        clamp_notes.push(localized(move || {
1333            resolve_message_widget("validation-clamped-to-range", &[])
1334        }));
1335    }
1336
1337    let formatted = format_value(pattern, Some(final_date), None);
1338    let formatted_for_msg = formatted.clone();
1339    let message = if clamp_notes.is_empty() {
1340        localized(move || {
1341            resolve_message_widget(
1342                "validation-corrected-to",
1343                &[("value", formatted_for_msg.clone().into())],
1344            )
1345        })
1346    } else {
1347        // For the notes case, we need to resolve all notes and join them.
1348        // We'll resolve them at display time.
1349        localized(move || {
1350            let notes_str: String = clamp_notes
1351                .iter()
1352                .map(|n| n.resolve_now())
1353                .collect::<Vec<_>>()
1354                .join(", ");
1355            resolve_message_widget(
1356                "validation-corrected-with-notes",
1357                &[("notes", notes_str.into())],
1358            )
1359        })
1360    };
1361    Some((formatted, message))
1362}