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