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}