Skip to main content

teksilo_widgets/
date_range_edit.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DateRangeEdit` — single unified control for picking a `DateRange`.
5//!
6//! Visually one widget: a single bordered frame containing two
7//! `TextInputField` halves separated by a painted arrow glyph, with
8//! a trailing built-in calendar button that opens a shared
9//! `Calendar::range` popover. Backed by `Signal<Option<DateRange>>`.
10//!
11//! ```text
12//! ┌──────────────────────────────────────┐
13//! │ 05/12/2026   →   05/19/2026   │ 📅  │
14//! └──────────────────────────────────────┘
15//! ```
16//!
17//! # Why one frame?
18//!
19//! Two adjacent `DateEdit`s (one frame each) visually read as two
20//! separate fields that happen to be next to each other. A single
21//! frame says "this is one range". Same affordance the user is used
22//! to from booking sites and analytics dashboards.
23//!
24//! # Behaviour
25//!
26//! - **Two text halves** — each masked from the resolved date pattern,
27//!   each with its own validator + segment-stepping (Up/Down on the
28//!   focused segment matches `DateEdit`).
29//! - **Painted arrow separator** — a thin chevron-right glyph, no text.
30//!   Visual only; AT users see the wrapper's `Role::DateInput`.
31//! - **One trailing calendar button** — Int UI `IconButton::embedded()` with
32//!   the calendar glyph. Opens a single popover hosting
33//!   `Calendar::range` bound to the outer signal. The two-anchor
34//!   click model (start-then-end) commits the range and closes the
35//!   popover. No per-half calendar buttons — there's only one
36//!   calendar, anchored to the wrapper.
37//! - **One frame** — focus-aware border (`BorderRole::Focused` while
38//!   any half holds focus, otherwise `Default`), validation-aware
39//!   border (`Error` for `Invalid`, `Focused` for `Corrected`).
40//! - **One validation strip** below the frame — composed feedback
41//!   from both halves (worse of the two wins).
42//!
43//! # Accessibility
44//!
45//! - Container: `Role::DateInput`, its value the two days in full joined
46//!   by words in the tree's locale ("du vendredi premier mai 2026 au mardi
47//!   5 mai 2026", "Friday, May 1, 2026 to Tuesday, May 5, 2026"), the same
48//!   `calendar-date-range` message the range calendar speaks.
49//! - Each `TextInputField` carries its own AT node, a `Role::TextInput`
50//!   named for its half ("Start date", "End date"); the wrapper's
51//!   `Role::DateInput` provides the range semantics. A half re-roled
52//!   `Role::DateInput` reached Orca as a date editor, which it reads
53//!   without its text.
54//! - The calendar opens on the start the field holds, on its days, with
55//!   no range begun, whatever an earlier opening left.
56//!
57//! ```ignore
58//! // Requires ctx.signal() — shown as ignore per convention.
59//! use teksilo_widgets::date_range_edit::DateRangeEdit;
60//! use jiff::civil::Weekday;
61//!
62//! let range = ctx.signal(None);
63//! let _w = DateRangeEdit::new(range.clone())
64//!     .first_day_of_week(Weekday::Monday)
65//!     .on_value_changed(|r, _ctx| println!("{r:?}"));
66//! ```
67//!
68//! ## Touch and pen
69//!
70//! See [`DateEdit`](crate::date_edit)'s "Touch and pen" section.
71
72#[cfg(test)]
73mod tests;
74
75use std::rc::Rc;
76use teksilo_i18n::localized;
77
78use jiff::civil::Weekday;
79use teksilo_canvas::{Path, Point, Rect, SizeProposal};
80use teksilo_core::accessibility::AccessNodeBuilder;
81use teksilo_core::accesskit::{Action, Role};
82use teksilo_core::build_context::BuildContext;
83use teksilo_core::event::{EventResponse, Key, WidgetEvent};
84use teksilo_core::overlay::{
85    DismissBehavior, OverlayDismissCallback, OverlayPlacement, OverlayRequest,
86};
87use teksilo_core::signal::{Prop, Signal};
88use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
89use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
90use teksilo_core::widget_id::WidgetId;
91use teksilo_i18n::{FluentValue, LanguageIdentifier, resolve_message_widget};
92use teksilo_tokens::{BorderRole, CornerRadius, SurfaceRole};
93
94use crate::calendar::{Calendar, DateRange};
95use crate::common::datetime::Date;
96use crate::common::datetime::pattern::{
97    ParseTarget, ParsedPattern, ParsedValue, format_value, mask_for_pattern, parse_value,
98    segment_at_position, step_date_field,
99};
100use crate::common::datetime::types::today_local;
101use crate::common::datetime::written::{date_locale, full_date};
102use crate::date_edit::{ValidationBehavior, build_date_validator, calendar_glyph_icon, clamp_date};
103use crate::icon_button::{IconButton, IconButtonSize};
104use crate::primitives::text_input_field::{TextInputField, ValidationFeedback};
105use crate::primitives::{
106    Center, FixedSize, HStack, IconWidget, MinSize, Padding, RectWidget, VStack, ZStack,
107};
108use teksilo_i18n::LocalizedString;
109
110type OnRangeChanged = Rc<dyn Fn(Option<DateRange>, &mut EventContext)>;
111
112/// Two-handle date picker over `Signal<Option<DateRange>>`. See the
113/// [module docs](self) for the visual layout and behaviour.
114pub struct DateRangeEdit {
115    value: Signal<Option<DateRange>>,
116    /// Internal start half — drives the start `TextInputField` text
117    /// signal and is kept in sync with `value` via `ctx.effect`.
118    start_part: Signal<Option<Date>>,
119    end_part: Signal<Option<Date>>,
120    start_text: Signal<String>,
121    end_text: Signal<String>,
122    min_date: Option<Date>,
123    max_date: Option<Date>,
124    pattern: Option<String>,
125    placeholder_start: LocalizedString,
126    placeholder_end: LocalizedString,
127    first_day_of_week: Option<Weekday>,
128    /// Enabled state, static or reactive; forwarded to the arena at
129    /// build time.
130    enabled: Prop<bool>,
131    read_only: bool,
132    label: Option<LocalizedString>,
133    validation_behavior: ValidationBehavior,
134    /// How the trailing (end) half claims horizontal space. The
135    /// leading (start) half always sizes to its mask-derived
136    /// natural width — the start date stays put while the end half
137    /// either matches that natural width
138    /// (`WidthPolicy::Default`) or absorbs whatever extra space
139    /// the parent offers (`WidthPolicy::Fill`).
140    end_width_policy: crate::date_edit::WidthPolicy,
141    /// Composed validation feedback (severity-merged from both halves).
142    feedback: Signal<ValidationFeedback>,
143    /// `true` while either half holds keyboard focus — drives the
144    /// unified frame border.
145    focused: Signal<bool>,
146    /// `true` while the calendar popover is open — drives the
147    /// open/close toggle and gates the deferred calendar subtree.
148    range_popover_open: Signal<bool>,
149    on_value_changed: Option<OnRangeChanged>,
150    style_override: Option<teksilo_core::styles::SharedDateEditStyle>,
151    root_child_id: Option<WidgetId>,
152    /// The locale the accessibility value writes the dates in, resolved
153    /// from the tree's locale in `build()`, which a locale switch re-runs.
154    lang: LanguageIdentifier,
155    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
156    /// with the rich / composite slots — every setter clears the other two so
157    /// the last call wins.
158    tooltip_text: Option<LocalizedString>,
159    /// Optional rich tooltip source (registry key or inline content).
160    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
161    /// Optional composite tooltip body (arbitrary widget tree).
162    composite_tooltip_content: Option<Box<dyn Widget>>,
163}
164
165impl std::fmt::Debug for DateRangeEdit {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("DateRangeEdit").finish_non_exhaustive()
168    }
169}
170
171impl DateRangeEdit {
172    /// Create a date-range picker bound to `value`.
173    pub fn new(value: Signal<Option<DateRange>>) -> Self {
174        let initial = value.get();
175        let start_part = Signal::new(initial.map(|r| r.start));
176        let end_part = Signal::new(initial.map(|r| r.end));
177        Self {
178            value,
179            start_part,
180            end_part,
181            start_text: Signal::new(String::new()),
182            end_text: Signal::new(String::new()),
183            min_date: None,
184            max_date: None,
185            pattern: None,
186            placeholder_start: LocalizedString::literal(String::new()),
187            placeholder_end: LocalizedString::literal(String::new()),
188            first_day_of_week: None,
189            enabled: Prop::Static(true),
190            read_only: false,
191            label: None,
192            validation_behavior: ValidationBehavior::AutoCorrect,
193            end_width_policy: crate::date_edit::WidthPolicy::Default,
194            feedback: Signal::new(ValidationFeedback::Pristine),
195            focused: Signal::new(false),
196            range_popover_open: Signal::new(false),
197            on_value_changed: None,
198            style_override: None,
199            root_child_id: None,
200            lang: date_locale(None),
201            tooltip_text: None,
202            rich_tooltip_source: None,
203            composite_tooltip_content: None,
204        }
205    }
206
207    /// Per-call DateEditStyle override (shared with DateEdit family).
208    pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self {
209        self.style_override = Some(std::rc::Rc::new(style));
210        self
211    }
212
213    /// Restrict the selectable start and end dates to those on or after `d`.
214    pub fn min_date(mut self, d: Date) -> Self {
215        self.min_date = Some(d);
216        self
217    }
218
219    /// Restrict the selectable start and end dates to those on or before `d`.
220    pub fn max_date(mut self, d: Date) -> Self {
221        self.max_date = Some(d);
222        self
223    }
224
225    /// Override the strftime-subset format pattern for both halves
226    /// (e.g. `"%d/%m/%Y"`). Defaults to the locale-derived pattern.
227    pub fn format_pattern(mut self, p: impl Into<String>) -> Self {
228        self.pattern = Some(p.into());
229        self
230    }
231
232    /// Placeholder shown in the start half when no date is set.
233    pub fn placeholder_start(mut self, text: impl Into<LocalizedString>) -> Self {
234        self.placeholder_start = text.into();
235        self
236    }
237
238    /// Placeholder shown in the end half when no date is set.
239    pub fn placeholder_end(mut self, text: impl Into<LocalizedString>) -> Self {
240        self.placeholder_end = text.into();
241        self
242    }
243
244    /// Override which weekday appears in the first column of the calendar popup.
245    pub fn first_day_of_week(mut self, w: Weekday) -> Self {
246        self.first_day_of_week = Some(w);
247        self
248    }
249
250    /// Set the enabled state, statically or reactively. Forwarded to the
251    /// arena at build time.
252    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
253        self.enabled = enabled.into();
254        self
255    }
256
257    /// Make both halves read-only; the calendar button is also disabled.
258    pub fn read_only(mut self, read_only: bool) -> Self {
259        self.read_only = read_only;
260        self
261    }
262
263    /// Accessible label for the wrapper `Role::DateInput` node. When not set,
264    /// falls back to the localized `date-range-edit-name` message.
265    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
266        let ls: LocalizedString = label.into();
267        self.label = Some(ls);
268        self
269    }
270
271    /// How both halves handle invalid or out-of-range text on blur / Enter.
272    /// Defaults to `ValidationBehavior::AutoCorrect`.
273    pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self {
274        self.validation_behavior = behavior;
275        self
276    }
277
278    /// How the trailing (end) half claims horizontal space. The
279    /// leading (start) half always sizes to its natural mask width;
280    /// the end half follows this policy. Default
281    /// `WidthPolicy::Default` (natural width); pass
282    /// `WidthPolicy::Fill` to make the end half absorb extra
283    /// space the parent offers.
284    pub fn end_width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self {
285        self.end_width_policy = policy;
286        self
287    }
288
289    /// Show a plain single-line tooltip on hover. Mutually exclusive with the
290    /// rich / composite tooltip slots — this setter clears the other two so the
291    /// last call wins.
292    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
293        self.tooltip_text = Some(text.into());
294        self.rich_tooltip_source = None;
295        self.composite_tooltip_content = None;
296        self
297    }
298
299    /// Show a rich tooltip sourced from the registry by `key`. Mutually
300    /// exclusive with the plain / composite tooltip slots — this setter clears
301    /// the other two so the last call wins.
302    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
303        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
304        self.tooltip_text = None;
305        self.composite_tooltip_content = None;
306        self
307    }
308
309    /// Show a rich tooltip from an inline `TooltipContent` value. Mutually
310    /// exclusive with the plain / registry-key tooltip slots — this setter
311    /// clears the other two so the last call wins.
312    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
313        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
314        self.tooltip_text = None;
315        self.composite_tooltip_content = None;
316        self
317    }
318
319    /// Show a composite tooltip whose body is an arbitrary widget tree. Mutually
320    /// exclusive with the plain / rich tooltip slots — this setter clears the
321    /// other two so the last call wins.
322    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
323        self.composite_tooltip_content = Some(Box::new(content));
324        self.tooltip_text = None;
325        self.rich_tooltip_source = None;
326        self
327    }
328
329    /// Reactive handle on the composed validation feedback (worse of the two
330    /// halves — `Invalid > Corrected > Valid > Pristine`).
331    pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback> {
332        self.feedback.clone()
333    }
334
335    /// Callback invoked whenever the range changes (including when one half
336    /// clears its value). Receives the new `Option<DateRange>` and an
337    /// `EventContext` for dispatching intents or side effects.
338    pub fn on_value_changed(
339        mut self,
340        f: impl Fn(Option<DateRange>, &mut EventContext) + 'static,
341    ) -> Self {
342        self.on_value_changed = Some(Rc::new(f));
343        self
344    }
345
346    /// Clone the underlying `Signal<Option<DateRange>>` for external binding.
347    pub fn value(&self) -> Signal<Option<DateRange>> {
348        self.value.clone()
349    }
350}
351
352impl Widget for DateRangeEdit {
353    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
354        let theme = ctx.theme_signal().get();
355        use crate::styles::recipe_date_edit_style as de;
356        use crate::styles::recipe_text_input_style as field_dims;
357        let focus_ring_width = theme.shape.focus_ring_width;
358        let self_id = ctx.self_id();
359        // Forward the enabled state into the arena; see IconButton.
360        ctx.enabled_when(self_id, self.enabled.clone());
361        let read_only = self.read_only;
362
363        // Resolve pattern — locale default unless overridden.
364        // A locale switch must re-derive the date pattern: it is read from
365        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
366        // only calls `mark_all_dirty` (layout + paint), which never re-runs
367        // `build()`. Without this binding the widget keeps rendering with
368        // the pattern of whatever locale was active when it was first
369        // built. Bound at `Rebuild` for the same reason `Calendar` binds
370        // the text scale there — the value is a build-time constant, so a
371        // relayout cannot pick it up.
372        ctx.locale_signal().bind_to(
373            ctx.self_id(),
374            ctx.binding_registry(),
375            teksilo_core::binding::BindingLevel::Rebuild,
376        );
377
378        self.lang = date_locale(ctx.locale_signal().get().as_deref());
379        let pattern_string = self.pattern.clone().unwrap_or_else(|| {
380            let tag = ctx.locale_signal().get().unwrap_or_default();
381            crate::common::datetime::format_pattern_for_locale(&tag).to_string()
382        });
383        let parsed_pattern = ParsedPattern::parse(&pattern_string)
384            .unwrap_or_else(|_| ParsedPattern::parse("%Y-%m-%d").unwrap());
385        let pattern_rc = Rc::new(parsed_pattern);
386        let mask_string = mask_for_pattern(&pattern_rc);
387        let min = self.min_date;
388        let max = self.max_date;
389
390        // Outer → halves: when the bound range changes externally,
391        // push start/end into the per-half date signals AND reformat
392        // their text. The text reformat is necessary so a programmatic
393        // `value.set(...)` shows up in the visible field, not just the
394        // hidden state.
395        {
396            let start_part = self.start_part.clone();
397            let end_part = self.end_part.clone();
398            let start_text = self.start_text.clone();
399            let end_text = self.end_text.clone();
400            let pattern = pattern_rc.clone();
401            ctx.effect(&self.value, move |new_range| {
402                let (s, e) = match new_range {
403                    Some(r) => (Some(r.start), Some(r.end)),
404                    None => (None, None),
405                };
406                if start_part.get() != s {
407                    start_part.set(s);
408                }
409                if end_part.get() != e {
410                    end_part.set(e);
411                }
412                let s_text = s
413                    .map(|d| format_value(&pattern, Some(d), None))
414                    .unwrap_or_default();
415                let e_text = e
416                    .map(|d| format_value(&pattern, Some(d), None))
417                    .unwrap_or_default();
418                if start_text.get() != s_text {
419                    start_text.set(s_text);
420                }
421                if end_text.get() != e_text {
422                    end_text.set(e_text);
423                }
424            });
425        }
426        // Seed text once at build time so the field shows the initial
427        // value without waiting for the first effect tick.
428        {
429            self.start_text.set(
430                self.start_part
431                    .get()
432                    .map(|d| format_value(&pattern_rc, Some(d), None))
433                    .unwrap_or_default(),
434            );
435            self.end_text.set(
436                self.end_part
437                    .get()
438                    .map(|d| format_value(&pattern_rc, Some(d), None))
439                    .unwrap_or_default(),
440            );
441        }
442
443        // ── Build each half as a bare TextInputField ───────────
444        // Each half returns (layout wrapper, inner editable field id).
445        let (start_field_id, start_inner_id) = self.build_half(
446            ctx,
447            HalfKind::Start,
448            pattern_rc.clone(),
449            &mask_string,
450            min,
451            max,
452        );
453        let (end_field_id, end_inner_id) = self.build_half(
454            ctx,
455            HalfKind::End,
456            pattern_rc.clone(),
457            &mask_string,
458            min,
459            max,
460        );
461
462        // ── Painted arrow separator ────────────────────────────
463        let separator_icon = arrow_right_icon(field_dims::TEXT_FIELD_HEIGHT * 0.45)
464            .color(teksilo_tokens::TextRole::Secondary);
465        let separator_id = ctx.add(
466            FixedSize::new()
467                .width(field_dims::TEXT_FIELD_HEIGHT * 0.65)
468                .height(field_dims::TEXT_FIELD_HEIGHT)
469                .child(Center::new().child(separator_icon)),
470        );
471
472        // ── Trailing calendar trigger ──────────────────────────
473        // Pre-build the dormant range calendar once.
474        let value_for_cal = self.value.clone();
475        let popover_open_for_cal = self.range_popover_open.clone();
476        let mut cal =
477            Calendar::range(value_for_cal.clone()).on_range_changed(move |new_range, ctx_evt| {
478                if new_range.is_some() {
479                    popover_open_for_cal.set(false);
480                    ctx_evt.dismiss_self_overlay_chain();
481                    ctx_evt.request_frame();
482                }
483            });
484        if let Some(min) = self.min_date {
485            cal = cal.min_date(min);
486        }
487        if let Some(max) = self.max_date {
488            cal = cal.max_date(max);
489        }
490        if let Some(fdow) = self.first_day_of_week {
491            cal = cal.first_day_of_week(fdow);
492        }
493        let opening = cal.opening();
494        // Detached rather than a child, and owned rather than orphaned — see
495        // `DateEdit`'s calendar for why both halves matter.
496        // Built the first time the popup is opened, not on every rebuild of the
497        // field. See `teksilo_core::deferred_subtree::DeferredSubtree`.
498        let cal_id = ctx.add_detached_deferred(self.range_popover_open.clone(), cal);
499        ctx.set_dormant(cal_id);
500
501        let popover_open = self.range_popover_open.clone();
502        let self_ref = ctx.self_id();
503        let dismiss_cb: OverlayDismissCallback = {
504            let popover_open = popover_open.clone();
505            Rc::new(move |_, _| {
506                popover_open.set(false);
507            })
508        };
509        let trigger_enabled = self.enabled.as_signal().map(move |on| *on && !read_only);
510        let range = self.value.clone();
511        let trigger_btn = IconButton::new(calendar_glyph_icon(de::CALENDAR_ICON_SIZE))
512            .embedded()
513            .size(IconButtonSize::Default)
514            .enabled(trigger_enabled)
515            .tooltip(localized(move || {
516                resolve_message_widget("date-range-edit-trigger-tooltip", &[])
517            }))
518            .on_activate_fn(move |ctx_evt: &mut EventContext| {
519                if popover_open.get() {
520                    popover_open.set(false);
521                    ctx_evt.dismiss_all_except_hosts();
522                } else {
523                    // On the start the field holds, with no range begun,
524                    // whatever the last opening left; see `DateEdit`'s calendar.
525                    opening.open_on(range.get().map(|r| r.start));
526                    popover_open.set(true);
527                    // Build the popup if this is its first open, before the overlay
528                    // below is measured against it and focus moves into it.
529                    ctx_evt.materialize_now(cal_id);
530                    ctx_evt.activate(cal_id);
531                    ctx_evt.show_overlay(
532                        OverlayRequest::new(
533                            cal_id,
534                            self_ref,
535                            OverlayPlacement::BelowPreferred,
536                            DismissBehavior::EscapeOrClickOutside,
537                        )
538                        .on_dismiss(dismiss_cb.clone()),
539                    );
540                    ctx_evt.request_focus(cal_id);
541                }
542            });
543        let trigger_id = ctx.add(trigger_btn);
544
545        // ── Row layout ─────────────────────────────────────────
546        // No divider before the trailing trigger — Int UI's
547        // embedded IconButton sits flush inside the field's trailing slot
548        // (the same convention TextInput uses) and the button's own
549        // hover/pressed background gives it enough visual separation.
550        // Each half is wrapped in `Shrinkable` so the row can compress them
551        // when the unified frame is narrower than the combined natural mask
552        // width — the `TextInputField` inside then scrolls instead of
553        // overflowing. `Shrinkable` keeps each half's natural width when there
554        // is room, so the wide-case layout is unchanged.
555        let start_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child(start_field_id));
556        let end_shrinkable = ctx.add(crate::primitives::Shrinkable::new().child(end_field_id));
557        let row = HStack::new()
558            .spacing(0.0)
559            .child(start_shrinkable)
560            .child(separator_id)
561            .child(end_shrinkable)
562            .child(trigger_id);
563        let inline_row_id = ctx.add(row);
564        let row_id = ctx.add(
565            Padding::new(
566                0.0,
567                field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
568                0.0,
569                field_dims::TEXT_FIELD_PADDING_HORIZONTAL,
570            )
571            .child(inline_row_id),
572        );
573
574        // ── Frame: bg + border driven by disabled + focus + validation ───
575        // This widget frames its two inner fields itself rather than
576        // delegating to `RecipeTextInputStyle`, so it has to opt into the
577        // neutral disabled roles the same way that recipe does — the
578        // accent-only substitution in `ColorProp::resolve` leaves
579        // `Content` / `Default` alone. Disabled outranks validation: an
580        // inert field must not shout an error the user cannot act on.
581        let feedback_for_border = self.feedback.clone();
582        let focused_for_border = self.focused.clone();
583        let is_disabled = ctx.effective_enabled_signal(self_id).map(|on| !*on);
584        let border_role = focused_for_border
585            .clone()
586            .zip3(&feedback_for_border, &is_disabled)
587            .map(|(focused, fb, disabled)| {
588                if *disabled {
589                    return BorderRole::Disabled;
590                }
591                match fb {
592                    ValidationFeedback::Invalid { .. } => BorderRole::Error,
593                    ValidationFeedback::Corrected { .. } if !*focused => BorderRole::Focused,
594                    _ => {
595                        if *focused {
596                            BorderRole::Focused
597                        } else {
598                            BorderRole::Field
599                        }
600                    }
601                }
602            });
603        let border_width_signal =
604            focused_for_border
605                .clone()
606                .zip(&feedback_for_border)
607                .map(move |(focused, fb)| {
608                    if *focused || matches!(fb, ValidationFeedback::Invalid { .. }) {
609                        focus_ring_width
610                    } else {
611                        field_dims::TEXT_FIELD_BORDER_WIDTH
612                    }
613                });
614        let bg = RectWidget::new()
615            .background(SurfaceRole::Field)
616            .border_color(border_role)
617            .border_width(border_width_signal)
618            .corner_radius(CornerRadius::uniform(field_dims::TEXT_FIELD_CORNER_RADIUS));
619        let bg_id = ctx.add(bg);
620        let framed_id = ctx.add(ZStack::new().child(bg_id).child(row_id));
621        let sized_id = ctx.add(
622            MinSize::new(
623                0.0,
624                crate::styles::TextInputRecipe::for_tokens(&ctx.theme().input).height,
625            )
626            .child(framed_id),
627        );
628
629        // ── Inline validation strip below the frame ───────────
630        let strip_id = ctx.add(crate::primitives::ValidationStrip::new(
631            self.feedback.clone(),
632        ));
633        // WCAG 3.3.1 / 3.3.3: both editable halves are described by the shared
634        // validation message. The tree writes it into each half's description,
635        // which a reader hears on arriving at either; the relation alone
636        // reaches no adapter. See the same wiring in `TextInput`.
637        ctx.access_described_by(start_inner_id, strip_id);
638        ctx.access_described_by(end_inner_id, strip_id);
639        // Wrap the frame in `Expand::horizontal().respect_intrinsic()` so it
640        // claims the VStack's full width (a VStack doesn't stretch a child),
641        // while keeping its natural width as the basis when unconstrained. A
642        // bounded proposal narrows it and the `Shrinkable` halves compress.
643        let framed_in_vstack = ctx.add(
644            crate::primitives::Expand::horizontal()
645                .respect_intrinsic()
646                .child(sized_id),
647        );
648        let root_with_strip = ctx.add(
649            VStack::new()
650                .spacing(field_dims::TEXT_FIELD_VALIDATION_STRIP_GAP)
651                .child(framed_in_vstack)
652                .child(strip_id),
653        );
654        let style = crate::styles::recipe_date_edit_style::resolve_date_edit_style(
655            &self.style_override,
656            ctx,
657        );
658        let cfg = teksilo_core::styles::DateEditStyleConfig {
659            body: root_with_strip,
660        };
661        let root_id = style.make_body(&cfg, ctx);
662        self.root_child_id = Some(root_id);
663
664        // ── Tooltip attachment ─────────────────────────────────
665        if let Some(content) = self.composite_tooltip_content.take() {
666            let delay = ctx.theme().motion.tooltip_delay_heavy;
667            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
668        } else if let Some(source) = self.rich_tooltip_source.clone() {
669            let delay = ctx.theme().motion.tooltip_delay;
670            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
671        } else if let Some(text) = self.tooltip_text.clone() {
672            let delay = ctx.theme().motion.tooltip_delay;
673            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
674        }
675
676        // ── Self handlers: focus_within drives the frame border ─
677        let handlers = HandlerSet::new().focus_within(self.focused.clone());
678        ctx.apply_self_handlers(handlers);
679
680        // Bind the value at AccessibilityOnly so the wrapper's AT
681        // node refreshes set_value when either half mutates.
682        let self_id = ctx.self_id();
683        self.value.bind_to(
684            self_id,
685            ctx.binding_registry(),
686            teksilo_core::binding::BindingLevel::AccessibilityOnly,
687        );
688        self.feedback.bind_to(
689            self_id,
690            ctx.binding_registry(),
691            teksilo_core::binding::BindingLevel::AccessibilityOnly,
692        );
693        self.range_popover_open.bind_to(
694            self_id,
695            ctx.binding_registry(),
696            teksilo_core::binding::BindingLevel::AccessibilityOnly,
697        );
698        // The trigger's own AT node (`set_expanded` / `HasPopup`) is not
699        // surfaced separately yet; the button itself is already mounted in
700        // the row above.
701        let _ = trigger_id;
702
703        vec![root_with_strip]
704    }
705
706    fn layout_response(
707        &self,
708        proposal: SizeProposal,
709        ctx: &LayoutContext,
710    ) -> teksilo_core::widget::LayoutResponse {
711        // Forward the inner LayoutResponse, then overlay flex=1 when
712        // the end half is Fill — the inner HStack consumes its
713        // children's flex internally and reports flex=0 to its
714        // parents, so the outer wrapper has to advertise flex
715        // explicitly for parent stacks to allocate slack.
716        let response = match self.root_child_id {
717            Some(id) => ctx
718                .child_layout_response(id, proposal)
719                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
720            None => proposal.resolve(0.0, 0.0).into(),
721        };
722        if self.end_width_policy == crate::date_edit::WidthPolicy::Fill {
723            teksilo_core::widget::LayoutResponse::flexible(response.size, 1.0)
724        } else {
725            response
726        }
727    }
728
729    fn place_children(
730        &self,
731        bounds: Rect,
732        _proposal: SizeProposal,
733        children: &mut [WidgetPlacement],
734        _ctx: &LayoutContext,
735    ) {
736        for child in children.iter_mut() {
737            child.origin = bounds.origin();
738            child.size = bounds.size();
739        }
740    }
741
742    fn children(&self) -> Vec<WidgetId> {
743        self.root_child_id.into_iter().collect()
744    }
745
746    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
747        builder.set_role(Role::DateInput);
748        if let Some(ref label) = self.label {
749            builder.set_name(label.clone());
750        } else {
751            builder.set_name(resolve_message_widget("date-range-edit-name", &[]));
752        }
753        match self.value.get() {
754            Some(r) => {
755                builder.set_value(resolve_message_widget(
756                    "calendar-date-range",
757                    &[
758                        ("start", FluentValue::from(full_date(r.start, &self.lang))),
759                        ("end", FluentValue::from(full_date(r.end, &self.lang))),
760                    ],
761                ));
762            }
763            None => {
764                builder.set_placeholder(resolve_message_widget("date-range-edit-placeholder", &[]));
765            }
766        }
767        // Framework a11y walker sets `set_disabled` from arena state.
768        if self.read_only {
769            builder.set_read_only();
770        }
771        if matches!(self.feedback.get(), ValidationFeedback::Invalid { .. }) {
772            builder
773                .inner_mut()
774                .set_invalid(teksilo_core::accesskit::Invalid::True);
775        }
776        builder.add_action(Action::Focus);
777    }
778}
779
780#[derive(Clone, Copy)]
781enum HalfKind {
782    Start,
783    End,
784}
785
786impl DateRangeEdit {
787    /// Build one half (start or end) as a bare `TextInputField` with
788    /// mask + validator + segment-stepping wired against the
789    /// appropriate per-half text/date signals. Returns
790    /// `(layout wrapper, inner editable field)` — the wrapper is the
791    /// `ZStack` carrying the segment-stepping key preview, sized
792    /// according to `end_width_policy`.
793    #[allow(clippy::too_many_arguments)]
794    fn build_half(
795        &self,
796        ctx: &mut BuildContext,
797        kind: HalfKind,
798        pattern_rc: Rc<ParsedPattern>,
799        mask_string: &str,
800        min: Option<Date>,
801        max: Option<Date>,
802    ) -> (WidgetId, WidgetId) {
803        use crate::styles::recipe_text_input_style as field_dims;
804        let (text_signal, date_signal, placeholder, other_date) = match kind {
805            HalfKind::Start => (
806                self.start_text.clone(),
807                self.start_part.clone(),
808                self.placeholder_start.clone(),
809                self.end_part.clone(),
810            ),
811            HalfKind::End => (
812                self.end_text.clone(),
813                self.end_part.clone(),
814                self.placeholder_end.clone(),
815                self.start_part.clone(),
816            ),
817        };
818
819        let validator =
820            build_date_validator(pattern_rc.clone(), min, max, self.validation_behavior);
821
822        let outer_value = self.value.clone();
823        let on_changed = self.on_value_changed.clone();
824        let merge_into_outer =
825            move |new_d: Option<Date>, other_d: Option<Date>, ctx_evt: &mut EventContext| {
826                let combined = match kind {
827                    HalfKind::Start => match (new_d, other_d) {
828                        (Some(s), Some(e)) => Some(DateRange::new(s, e)),
829                        _ => None,
830                    },
831                    HalfKind::End => match (other_d, new_d) {
832                        (Some(s), Some(e)) => Some(DateRange::new(s, e)),
833                        _ => None,
834                    },
835                };
836                if outer_value.get() != combined {
837                    outer_value.set(combined);
838                    if let Some(cb) = on_changed.as_ref() {
839                        cb(combined, ctx_evt);
840                    }
841                }
842            };
843
844        // Commit closure: parse the field text on Enter / blur, sync
845        // the per-half date signal, then merge into the outer range.
846        // Returns whether the text committed — see `DateEdit`'s twin.
847        let commit: Rc<dyn Fn(&mut EventContext) -> bool> = {
848            let text_signal = text_signal.clone();
849            let date_signal = date_signal.clone();
850            let other_date = other_date.clone();
851            let pattern = pattern_rc.clone();
852            let merge = merge_into_outer.clone();
853            Rc::new(move |ctx_evt: &mut EventContext| {
854                let raw = text_signal.get();
855                let trimmed = raw.trim();
856                let (parsed, accepted): (Option<Date>, bool) = if trimmed.is_empty() {
857                    (None, true)
858                } else {
859                    match parse_value(&pattern, trimmed, ParseTarget::DateOnly) {
860                        Some(ParsedValue::Date(d)) => (Some(clamp_date(d, min, max)), true),
861                        _ => (date_signal.get(), false),
862                    }
863                };
864                if date_signal.get() != parsed {
865                    date_signal.set(parsed);
866                }
867                merge(parsed, other_date.get(), ctx_evt);
868                accepted
869            })
870        };
871
872        let inner_height =
873            (field_dims::TEXT_FIELD_HEIGHT - 2.0 * field_dims::TEXT_FIELD_BORDER_WIDTH).max(0.0);
874        let text_area_height =
875            (inner_height - 2.0 * field_dims::TEXT_FIELD_PADDING_VERTICAL).max(0.0);
876
877        let pattern_for_filter = pattern_rc.clone();
878        let mut field = TextInputField::new(text_signal.clone())
879            .enabled(self.enabled.clone())
880            // An assistive-technology `SetValue` on the inner text node is a
881            // finished edit, not a keystroke: push the string the technology
882            // set and run the same commit `Enter` runs, or the typed value
883            // stays stale behind a display nothing ever parses.
884            .on_access_set_value({
885                let text_signal = text_signal.clone();
886                let commit = commit.clone();
887                // The commit's verdict is the technology's answer: an
888                // unparseable string leaves the value where it was, and
889                // saying `Handled` there would report a write that never
890                // landed.
891                move |text: &str, ctx: &mut EventContext| {
892                    text_signal.set(text.to_string());
893                    commit(ctx)
894                }
895            })
896            .read_only(self.read_only)
897            .placeholder(placeholder)
898            .text_height(text_area_height)
899            .input_mask(mask_string)
900            .validator({
901                let v = validator.clone();
902                move |s| (v)(s)
903            })
904            .char_filter(move |c: char| {
905                if c.is_ascii_digit() || c == '-' || c == ' ' {
906                    return true;
907                }
908                for tok in &pattern_for_filter.tokens {
909                    if let crate::common::datetime::pattern::PatternToken::Literal(s) = tok
910                        && s.chars().any(|x| x == c)
911                    {
912                        return true;
913                    }
914                }
915                false
916            });
917        // Mirror the inner field's feedback into the outer composed
918        // feedback signal (worse-of-two semantics).
919        {
920            let inner_feedback = field.validation_feedback_signal();
921            let composed = self.feedback.clone();
922            let other_feedback_owner = match kind {
923                HalfKind::Start => Some(self.feedback.clone()), // placeholder, replaced below
924                HalfKind::End => Some(self.feedback.clone()),
925            };
926            // The other half's feedback isn't accessible at this point
927            // (it's inside its own field). Compose via a per-half
928            // mirror: each half's effect computes max(self, current
929            // composed) → composed. As long as both halves install
930            // this, the worse always wins.
931            let _ = other_feedback_owner;
932            ctx.effect(&inner_feedback, move |new_fb| {
933                let merged = match (composed.get(), new_fb.clone()) {
934                    (a, b) if rank(&a) >= rank(&b) => a,
935                    (_, b) => b,
936                };
937                if composed.get() != merged {
938                    composed.set(merged);
939                }
940            });
941        }
942        {
943            let commit = commit.clone();
944            field = field.on_submit_fn(move |ctx_evt| {
945                commit(ctx_evt);
946            });
947        }
948        {
949            let commit = commit.clone();
950            field = field.on_blur_fn(move |ctx_evt| {
951                commit(ctx_evt);
952            });
953        }
954
955        // Capture caret signal + caret_setter BEFORE moving the field
956        // into the tree, for segment-stepping.
957        let caret = field.caret_position();
958        let caret_setter = field.caret_setter();
959
960        // A11y: an editable text field named for its half, which a reader
961        // reads with its text. It was re-roled `Role::DateInput`, which the
962        // AT-SPI adapter hands a reader as a date editor, and Orca reads a
963        // date editor without its text: "End date date editor.".
964        let half_label_key = match kind {
965            HalfKind::Start => "date-range-edit-start-name",
966            HalfKind::End => "date-range-edit-end-name",
967        };
968        let field_with_a11y = field.access_label(resolve_message_widget(half_label_key, &[]));
969        let field_id = ctx.add(field_with_a11y);
970
971        // Padding around the field for visual alignment with the
972        // separator icon and trigger button.
973        let padded_field_id = ctx.add(
974            Padding::new(
975                field_dims::TEXT_FIELD_PADDING_VERTICAL,
976                4.0,
977                field_dims::TEXT_FIELD_PADDING_VERTICAL,
978                4.0,
979            )
980            .child(field_id),
981        );
982        // Width policy: start half is always at its natural mask
983        // width (so the start date doesn't reflow when only the end
984        // changes); end half follows `end_width_policy`. `Default`
985        // matches the start (fixed). `Fill` wraps in an
986        // `Expand::horizontal()` (zero-basis flex=1) so the end
987        // half absorbs the unified frame's leftover width.
988        let sized_field_id = match (kind, self.end_width_policy) {
989            (HalfKind::End, crate::date_edit::WidthPolicy::Fill) => {
990                ctx.add(crate::primitives::Expand::horizontal().child(padded_field_id))
991            }
992            _ => padded_field_id,
993        };
994
995        // ── Segment-stepping (Up/Down on focused segment) ──────
996        let segment_step: Rc<dyn Fn(i32, &mut EventContext)> = {
997            let pattern = pattern_rc.clone();
998            let date_signal = date_signal.clone();
999            let text_signal = text_signal.clone();
1000            let other_date = other_date.clone();
1001            let merge = merge_into_outer.clone();
1002            Rc::new(move |delta: i32, ctx_evt: &mut EventContext| {
1003                let pos = caret.get();
1004                let Some((_, _, kind_seg)) = segment_at_position(&pattern, pos) else {
1005                    return;
1006                };
1007                let current = date_signal.get().unwrap_or_else(today_local);
1008                let stepped = step_date_field(current, kind_seg, delta);
1009                let clamped = clamp_date(stepped, min, max);
1010                date_signal.set(Some(clamped));
1011                text_signal.set(format_value(&pattern, Some(clamped), None));
1012                caret_setter(pos);
1013                merge(Some(clamped), other_date.get(), ctx_evt);
1014                ctx_evt.request_frame();
1015            })
1016        };
1017
1018        // Attach key preview on a strict ancestor of the field — same
1019        // pattern DateEdit uses for its ±segment stepping. No manual
1020        // `enabled` gate here: dispatch is already centrally gated by
1021        // `arena.is_enabled()` (walking up from the focused field
1022        // through this ZStack to the composite root's `enabled_when`)
1023        // before any handler — including `on_key_preview` — runs.
1024        let read_only = self.read_only;
1025        let step_for_key = segment_step.clone();
1026
1027        let stepping_id = ctx.add(ZStack::new().child(sized_field_id).on_key_preview(
1028            move |event, ctx_evt| {
1029                if read_only {
1030                    return EventResponse::Ignored;
1031                }
1032                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1033                    return EventResponse::Ignored;
1034                };
1035                let mult = if modifiers.shift() { 10 } else { 1 };
1036                let delta = match key {
1037                    Key::ArrowUp => mult,
1038                    Key::ArrowDown => -mult,
1039                    Key::PageUp => 10 * mult,
1040                    Key::PageDown => -10 * mult,
1041                    _ => return EventResponse::Ignored,
1042                };
1043                step_for_key(delta, ctx_evt);
1044                EventResponse::Handled
1045            },
1046        ));
1047        // (layout wrapper, inner editable field) so the caller can wire
1048        // `described_by` onto the editable node itself.
1049        (stepping_id, field_id)
1050    }
1051}
1052
1053/// Severity rank for `ValidationFeedback`. Higher = more severe.
1054fn rank(fb: &ValidationFeedback) -> u8 {
1055    match fb {
1056        ValidationFeedback::Invalid { .. } => 3,
1057        ValidationFeedback::Corrected { .. } => 2,
1058        ValidationFeedback::Valid => 1,
1059        ValidationFeedback::Pristine => 0,
1060    }
1061}
1062
1063/// Painted right-arrow chevron used as the visual separator
1064/// between the start and end halves. Same stroke convention as
1065/// the calendar header chevrons.
1066fn arrow_right_icon(size: f32) -> IconWidget {
1067    let mut path = Path::new();
1068    let s = size;
1069    // Single chevron pointing right: `>`
1070    path.move_to(Point::new(s * 0.35, s * 0.20));
1071    path.line_to(Point::new(s * 0.70, s * 0.50));
1072    path.line_to(Point::new(s * 0.35, s * 0.80));
1073    IconWidget::from_path(path, size)
1074}