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