Skip to main content

teksilo_widgets/
calendar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Calendar` — month-grid date picker, standalone widget.
5//!
6//! A self-contained calendar with month/year navigation, a 6×7 day grid,
7//! keyboard navigation matching the WAI-ARIA grid pattern, and full
8//! AccessKit instrumentation (`Role::Grid` + per-cell `Role::GridCell`).
9//! Used standalone for event apps and scheduling, and embedded in
10//! [`DateEdit`](crate::date_edit::DateEdit)'s popover.
11//!
12//! # Selection modes
13//!
14//! - [`Calendar::single`] — pick one day. Bound to `Signal<Option<Date>>`.
15//! - [`Calendar::range`] — pick a start + end day. Bound to
16//!   `Signal<Option<DateRange>>`. Click first day → click second day to
17//!   commit. Escape mid-selection cancels the in-progress anchor.
18//!
19//! # Behaviour
20//!
21//! - **Visible month** is independent of the selection — navigating past
22//!   the selected month doesn't lose the selection.
23//! - **Today highlight** draws a ring around today's cell whenever it's
24//!   in the visible month. Color comes from `TextRole::Accent`.
25//! - **Out-of-month cells** (the leading days from the previous month
26//!   and trailing days from the next month that fill the 6×7 grid) are
27//!   rendered with `TextRole::Disabled` and remain selectable (matching
28//!   macOS / Material). To prevent selection use
29//!   `disabled_date_filter`.
30//! - **Keyboard** (matches the WAI-ARIA `grid` pattern):
31//!   - Arrow keys: move focus by one day.
32//!   - Home / End: first / last day of week.
33//!   - Ctrl+Home / Ctrl+End: first / last day of month.
34//!   - PageUp / PageDown: previous / next month.
35//!   - Shift+PageUp / Shift+PageDown: previous / next year.
36//!   - Enter / Space: commit focused day to selection.
37//!   - Escape: in range mode mid-selection, cancel anchor; otherwise
38//!     bubble (popover hosts close).
39//!   - `T`: jump focus to today.
40//!
41//! # Accessibility
42//!
43//! - Container — `Role::Grid` with `set_name("Calendar, May 2026")`
44//!   (localized) and `set_live(Live::Polite)`, so every `set_value`
45//!   change is announced. That value always carries the
46//!   keyboard-focused day as `YYYY-MM-DD`, with `(selected: …)`
47//!   appended when a selection exists; a range renders as
48//!   `YYYY-MM-DD to YYYY-MM-DD`, ASCII " to " rather than an
49//!   en-dash because some screen readers skip U+2013.
50//! - Header arrow buttons — `Role::Button` with localized labels
51//!   ("Previous month", "Next month") and `Action::Click` advertised.
52//! - Header month/year label — `Role::Button` (clickable to open the
53//!   month picker) with `set_has_popup(HasPopup::Grid)` and
54//!   `set_expanded(open)`.
55//! - Weekday header row — `Role::Row` of `Role::ColumnHeader` cells,
56//!   each labelled with the long weekday name (e.g. "Monday").
57//! - Day cells — `Role::GridCell` with localized long-form labels
58//!   ("May 2, 2026"), `set_selected`, `set_focused`, `set_disabled` for
59//!   filter rejections, and `Action::Click` advertised.
60//!
61//! # Example
62//!
63//! ```ignore
64//! use teksilo::widgets::{Calendar, common::datetime::Date};
65//!
66//! let date = ctx.signal(Some(Date::constant(2026, 5, 2)));
67//! ctx.add(
68//!     Calendar::single(date.clone())
69//!         .show_today_button(true)
70//!         .on_selection_changed(|d, ctx| ctx.send_intent(MyIntent::DateChanged(d))),
71//! );
72//! ```
73
74mod cell;
75mod header;
76#[cfg(test)]
77mod tests;
78mod zoom_grid;
79
80use std::cell::RefCell;
81use std::rc::Rc;
82use teksilo_i18n::lit;
83
84use jiff::civil::Weekday;
85use teksilo_canvas::{Point, Rect, Size, SizeProposal};
86use teksilo_core::accessibility::AccessNodeBuilder;
87use teksilo_core::accesskit::{Action, Live, Role};
88use teksilo_core::build_context::BuildContext;
89use teksilo_core::event::{EventResponse, Key, WidgetEvent};
90use teksilo_core::signal::{Prop, Signal};
91use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
92use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
93use teksilo_core::widget_id::WidgetId;
94use teksilo_i18n::resolve_message_widget;
95use teksilo_tokens::{TextRole, TextStyleRole};
96
97use crate::button::{Button, ButtonVariant};
98use crate::common::datetime::Date;
99use crate::common::datetime::month_long_key;
100use crate::common::datetime::types::{YearMonth, today_local, weekday_from_monday_zero};
101use crate::common::datetime::weekday_short_key;
102use crate::primitives::{Center, Divider, FixedSize, HStack, Padding, Spacer, TextWidget, VStack};
103use crate::styles::recipe_calendar_style as cal_recipe;
104
105use self::cell::DayCell;
106use self::header::CalendarHeader;
107use teksilo_i18n::LocalizedString;
108
109// ── Public types ──────────────────────────────────────────────────────
110
111/// Inclusive range of two dates, with `start <= end` enforced at
112/// construction. Used by [`Calendar::range`].
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct DateRange {
115    pub start: Date,
116    pub end: Date,
117}
118
119impl DateRange {
120    /// Construct a range; swaps `start` and `end` if needed so the
121    /// invariant `start <= end` always holds.
122    pub fn new(a: Date, b: Date) -> Self {
123        if a <= b {
124            Self { start: a, end: b }
125        } else {
126            Self { start: b, end: a }
127        }
128    }
129
130    /// `true` iff `d` is between `start` and `end` inclusive.
131    pub fn contains(&self, d: Date) -> bool {
132        d >= self.start && d <= self.end
133    }
134}
135
136/// Selection mode discriminant — chosen at construction by picking
137/// between [`Calendar::single`] and [`Calendar::range`]. Stored
138/// internally; not part of the public surface.
139#[derive(Clone)]
140pub(crate) enum SelectionBinding {
141    Single(Signal<Option<Date>>),
142    Range {
143        value: Signal<Option<DateRange>>,
144        anchor: Signal<Option<Date>>,
145    },
146}
147
148/// What the calendar body is showing — drives the WPF/Avalonia
149/// "header-zoom" UX where clicking the title cycles to a coarser
150/// grid, letting the user reach any year in 2-3 clicks instead of
151/// many chevron presses. Default [`CalendarMode::Days`].
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum CalendarMode {
154    /// 6×7 day grid for the visible month. Title shows "May 2026".
155    /// Header chevrons step by ±1 month and ±1 year.
156    #[default]
157    Days,
158    /// 4×3 grid of months. Title shows "2026". Header chevrons step
159    /// by ±1 year. Picking a cell zooms back into [`Self::Days`].
160    Months,
161    /// 4×3 grid of years (current decade). Title shows "2020 — 2029".
162    /// Header chevrons step by ±10 years (one decade). Picking a cell
163    /// zooms back into [`Self::Months`].
164    Years,
165}
166
167impl CalendarMode {
168    /// Mode after demoting one level (clicking the header title).
169    /// `Years` is the coarsest level — no further demotion.
170    pub fn demote(self) -> Self {
171        match self {
172            Self::Days => Self::Months,
173            Self::Months => Self::Years,
174            Self::Years => Self::Years,
175        }
176    }
177}
178
179/// Whether and how week numbers are displayed in the leading column of the day grid.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum WeekNumberDisplay {
182    /// No week-number column (default).
183    #[default]
184    None,
185    /// ISO 8601 week number — week 1 is the week containing the first
186    /// Thursday of the year. Adds a narrow column to the left of the day grid.
187    Iso8601,
188}
189
190// ── Builder API ───────────────────────────────────────────────────────
191
192pub(crate) type DisabledDateFilter = Rc<dyn Fn(Date) -> bool>;
193pub(crate) type OnSelectionChanged = Rc<dyn Fn(Option<Date>, &mut EventContext)>;
194pub(crate) type OnRangeChanged = Rc<dyn Fn(Option<DateRange>, &mut EventContext)>;
195pub(crate) type OnMonthChanged = Rc<dyn Fn(YearMonth, &mut EventContext)>;
196pub(crate) type OnActivate = Rc<dyn Fn(Date, &mut EventContext)>;
197
198/// Standalone month-grid date picker. See the [module docs](self) for
199/// the full feature list and a usage example.
200pub struct Calendar {
201    selection: SelectionBinding,
202    visible_month: Signal<YearMonth>,
203    focused_date: Signal<Date>,
204    /// Body mode (Days / Months / Years). Owned so the header label
205    /// can demote it on click and the cells can promote it back
206    /// (Years cell → Months → Days). Default [`CalendarMode::Days`].
207    mode: Signal<CalendarMode>,
208    /// Optional custom override of the locale-derived first day of week.
209    first_day_of_week_override: Option<Weekday>,
210    week_numbers: WeekNumberDisplay,
211    show_today_button: bool,
212    show_navigation: bool,
213    min_date: Option<Date>,
214    max_date: Option<Date>,
215    disabled_date_filter: Option<DisabledDateFilter>,
216    label: Option<LocalizedString>,
217    /// Enabled state, static or reactive. Forwarded to the arena at
218    /// build time.
219    enabled: Prop<bool>,
220    on_selection_changed: Option<OnSelectionChanged>,
221    on_range_changed: Option<OnRangeChanged>,
222    on_month_changed: Option<OnMonthChanged>,
223    on_activate: Option<OnActivate>,
224    /// Status message shown at the bottom in range mode while a range
225    /// is committed.
226    range_status: Signal<String>,
227    /// `true` while the Calendar root holds keyboard focus. Drives the
228    /// roving-focus ring on the cell at `focused_date` so keyboard
229    /// users see where the next arrow key will land. Written by
230    /// `.on_focus()` in `build()`.
231    focused: Signal<bool>,
232    // Build state
233    root_child_id: Option<WidgetId>,
234}
235
236impl std::fmt::Debug for Calendar {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.debug_struct("Calendar")
239            .field("enabled", &self.enabled.get())
240            .finish_non_exhaustive()
241    }
242}
243
244impl Calendar {
245    /// Construct a calendar in single-selection mode bound to a
246    /// nullable date signal.
247    pub fn single(value: Signal<Option<Date>>) -> Self {
248        let initial = value.get().unwrap_or_else(today_local);
249        Self::new(SelectionBinding::Single(value), initial)
250    }
251
252    /// Construct a calendar in range-selection mode bound to a
253    /// nullable date-range signal.
254    pub fn range(value: Signal<Option<DateRange>>) -> Self {
255        let initial = value.get().map(|r| r.start).unwrap_or_else(today_local);
256        let anchor = Signal::new(None);
257        Self::new(SelectionBinding::Range { value, anchor }, initial)
258    }
259
260    fn new(selection: SelectionBinding, initial_focus: Date) -> Self {
261        Self {
262            selection,
263            visible_month: Signal::new(YearMonth::from_date(initial_focus)),
264            focused_date: Signal::new(initial_focus),
265            mode: Signal::new(CalendarMode::default()),
266            first_day_of_week_override: None,
267            week_numbers: WeekNumberDisplay::None,
268            show_today_button: false,
269            show_navigation: true,
270            min_date: None,
271            max_date: None,
272            disabled_date_filter: None,
273            label: None,
274            enabled: Prop::Static(true),
275            on_selection_changed: None,
276            on_range_changed: None,
277            on_month_changed: None,
278            on_activate: None,
279            range_status: Signal::new(String::new()),
280            focused: Signal::new(false),
281            root_child_id: None,
282        }
283    }
284
285    /// Override the locale-derived first day of the week.
286    pub fn first_day_of_week(mut self, w: Weekday) -> Self {
287        self.first_day_of_week_override = Some(w);
288        self
289    }
290
291    /// Show or hide the leading week-number column.
292    pub fn week_numbers(mut self, mode: WeekNumberDisplay) -> Self {
293        self.week_numbers = mode;
294        self
295    }
296
297    /// Show a "Today" button in the footer that jumps focus and selection
298    /// (in single mode) to today.
299    pub fn show_today_button(mut self, show: bool) -> Self {
300        self.show_today_button = show;
301        self
302    }
303
304    /// Show or hide the prev/next month navigation arrows.
305    pub fn show_navigation(mut self, show: bool) -> Self {
306        self.show_navigation = show;
307        self
308    }
309
310    /// Earliest allowed date; days before this read as disabled.
311    pub fn min_date(mut self, d: Date) -> Self {
312        self.min_date = Some(d);
313        self
314    }
315
316    /// Latest allowed date; days after this read as disabled.
317    pub fn max_date(mut self, d: Date) -> Self {
318        self.max_date = Some(d);
319        self
320    }
321
322    /// Per-cell predicate. `true` ⇒ cell is disabled (no click, no
323    /// keyboard commit, AT marks `disabled`).
324    pub fn disabled_date_filter(mut self, f: impl Fn(Date) -> bool + 'static) -> Self {
325        self.disabled_date_filter = Some(Rc::new(f));
326        self
327    }
328
329    /// Override the AT label. Default: "Calendar, May 2026" (localized,
330    /// derived from the visible month).
331    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
332        let ls: LocalizedString = label.into();
333        self.label = Some(ls);
334        self
335    }
336
337    /// Set the enabled state, statically or reactively. Forwarded to the
338    /// arena at build time — a bound `Signal<bool>` updates live.
339    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
340        self.enabled = enabled.into();
341        self
342    }
343
344    /// Fired when the selection changes. In range mode use
345    /// [`on_range_changed`](Self::on_range_changed) instead — this
346    /// callback fires on every committed-day change in range mode too,
347    /// passing the just-committed endpoint.
348    pub fn on_selection_changed(
349        mut self,
350        f: impl Fn(Option<Date>, &mut EventContext) + 'static,
351    ) -> Self {
352        self.on_selection_changed = Some(Rc::new(f));
353        self
354    }
355
356    /// Fired in range mode whenever a range is committed (second click
357    /// of the pair). `None` fires when the user resets via Escape or
358    /// when the bound value is externally cleared.
359    pub fn on_range_changed(
360        mut self,
361        f: impl Fn(Option<DateRange>, &mut EventContext) + 'static,
362    ) -> Self {
363        self.on_range_changed = Some(Rc::new(f));
364        self
365    }
366
367    /// Fired when the visible month changes (navigation arrows,
368    /// keyboard PageUp/Down, today jump).
369    pub fn on_month_changed(mut self, f: impl Fn(YearMonth, &mut EventContext) + 'static) -> Self {
370        self.on_month_changed = Some(Rc::new(f));
371        self
372    }
373
374    /// Fired in single mode on Enter or click (i.e. when the user
375    /// "double commits"). Distinct from selection change; popover hosts
376    /// use this to dismiss themselves only on a real click, not on
377    /// keyboard navigation.
378    pub fn on_activate(mut self, f: impl Fn(Date, &mut EventContext) + 'static) -> Self {
379        self.on_activate = Some(Rc::new(f));
380        self
381    }
382
383    /// Reactive accessor for the currently-visible month.
384    pub fn visible_month_signal(&self) -> Signal<YearMonth> {
385        self.visible_month.clone()
386    }
387
388    /// Reactive accessor for the focused-cell date.
389    pub fn focused_date_signal(&self) -> Signal<Date> {
390        self.focused_date.clone()
391    }
392
393    /// Reactive accessor for the body mode (Days / Months / Years).
394    /// Drives the header-zoom UX. Apps can read this to react to mode
395    /// changes, or write to it to programmatically zoom in/out.
396    pub fn mode_signal(&self) -> Signal<CalendarMode> {
397        self.mode.clone()
398    }
399}
400
401impl Widget for Calendar {
402    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
403        let theme = ctx.theme_signal().get();
404        let self_id = ctx.self_id();
405        // Global accessibility text scale: the calendar's cell/header sizes are
406        // fixed constants read at build, so a scale change must *rebuild* (a
407        // relayout won't recompute them). Bind the scale signal at `Rebuild`
408        // level — exactly like `visible_month` — and multiply every dimension
409        // constant by `scale` below. Rebuilding the Calendar reconstructs its
410        // header / weekday row / body, so they all pick up the new scale.
411        let scale = ctx.text_scale();
412        ctx.text_scale_signal().bind_to(
413            self_id,
414            ctx.binding_registry(),
415            teksilo_core::binding::BindingLevel::Rebuild,
416        );
417        // Forward the enabled state into the arena. After this point the
418        // arena is the single source of truth.
419        ctx.enabled_when(self_id, self.enabled.clone());
420        // Inner cell/grid helpers still take an `enabled: bool`
421        // snapshot which is fine for build-time decisions (they pass
422        // it to the inner widgets which now consult the arena).
423        let enabled = self.enabled.get();
424        let week_numbers = self.week_numbers;
425        let week_number_col_width = match week_numbers {
426            WeekNumberDisplay::None => 0.0,
427            _ => cal_recipe::CALENDAR_WEEK_NUMBER_COLUMN_WIDTH * scale,
428        };
429
430        // A locale switch must re-derive the first day of week: it is read from
431        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
432        // only calls `mark_all_dirty` (layout + paint), which never re-runs
433        // `build()`. Without this binding the widget keeps rendering with
434        // the pattern of whatever locale was active when it was first
435        // built. Bound at `Rebuild` for the same reason `Calendar` binds
436        // the text scale there — the value is a build-time constant, so a
437        // relayout cannot pick it up.
438        ctx.locale_signal().bind_to(
439            ctx.self_id(),
440            ctx.binding_registry(),
441            teksilo_core::binding::BindingLevel::Rebuild,
442        );
443
444        // Resolve first day of week: explicit override → locale default → Monday.
445        let first_dow = self.first_day_of_week_override.unwrap_or_else(|| {
446            let tag = ctx.locale_signal().get().unwrap_or_default();
447            crate::common::datetime::first_day_of_week_for_locale(&tag)
448        });
449
450        // ── Header (prev / month-label / next) ──────────────────
451        let header_id = if self.show_navigation {
452            ctx.add(CalendarHeader::new(
453                self.visible_month.clone(),
454                self.focused_date.clone(),
455                self.mode.clone(),
456                self.on_month_changed.clone(),
457            ))
458        } else {
459            // Empty placeholder so layout shape stays consistent.
460            ctx.add(FixedSize::new().width(0.0).height(0.0).child(Spacer::new()))
461        };
462
463        // ── Weekday header row ──────────────────────────────────
464        // Only meaningful in Days mode; hidden in Months/Years zoom.
465        let weekday_row_id = build_weekday_row(ctx, first_dow, week_number_col_width);
466        ctx.visible_when(
467            weekday_row_id,
468            self.mode.map(|m| matches!(m, CalendarMode::Days)),
469        );
470
471        // ── Body Switcher: Days / Months / Years ────────────────
472        // The mode signal drives a Switcher that mounts only the
473        // currently-active body. Day grid keeps all its existing
474        // wiring; the two zoom grids are minimal click-driven 4×3
475        // pickers that promote the visible_month and zoom back in
476        // when a cell is picked.
477        let day_body = self::CalendarBody::new(BuildGridParams {
478            visible_month: self.visible_month.clone(),
479            focused_date: self.focused_date.clone(),
480            focused: self.focused.clone(),
481            selection: self.selection.clone(),
482            first_dow,
483            week_numbers,
484            min_date: self.min_date,
485            max_date: self.max_date,
486            disabled_filter: self.disabled_date_filter.clone(),
487            enabled,
488            on_selection_changed: self.on_selection_changed.clone(),
489            on_range_changed: self.on_range_changed.clone(),
490            on_activate: self.on_activate.clone(),
491            range_status: self.range_status.clone(),
492        });
493        // Cell footprint for zoom modes derived from day grid cell
494        // size so the body's overall width matches the day grid (7
495        // day cells worth, divided across 3 zoom columns) and the
496        // calendar's outer width stays constant across mode flips.
497        let zoom_cell_height = (cal_recipe::CALENDAR_CELL_SIZE * 1.4).max(36.0) * scale;
498        let zoom_cell_width = (cal_recipe::CALENDAR_CELL_SIZE * 7.0 / 3.0).max(64.0) * scale;
499        let months_body = zoom_grid::MonthsGrid::new(
500            self.visible_month.clone(),
501            self.mode.clone(),
502            enabled,
503            zoom_cell_width,
504            zoom_cell_height,
505        );
506        let years_body = zoom_grid::YearsGrid::new(
507            self.visible_month.clone(),
508            self.mode.clone(),
509            enabled,
510            zoom_cell_width,
511            zoom_cell_height,
512        );
513        let mode_index = self.mode.map(|m| match m {
514            CalendarMode::Days => 0_usize,
515            CalendarMode::Months => 1,
516            CalendarMode::Years => 2,
517        });
518        let grid_id = ctx.add(
519            crate::primitives::Switcher::new(mode_index)
520                .child(day_body)
521                .child(months_body)
522                .child(years_body),
523        );
524
525        // ── Optional footer ─────────────────────────────────────
526        let footer_id =
527            if self.show_today_button || matches!(self.selection, SelectionBinding::Range { .. }) {
528                Some(build_footer(
529                    ctx,
530                    self.show_today_button,
531                    self.visible_month.clone(),
532                    self.focused_date.clone(),
533                    self.selection.clone(),
534                    self.on_selection_changed.clone(),
535                    self.on_month_changed.clone(),
536                    self.range_status.clone(),
537                    matches!(self.selection, SelectionBinding::Range { .. }),
538                ))
539            } else {
540                None
541            };
542
543        // ── Assemble VStack ─────────────────────────────────────
544        let mut col = VStack::new()
545            .spacing(cal_recipe::CALENDAR_SECTION_GAP * scale)
546            .add_child(header_id)
547            .add_child(weekday_row_id)
548            .add_child(grid_id);
549        if let Some(footer_id) = footer_id {
550            let divider_id = ctx.add(Divider::horizontal());
551            col = col.add_child(divider_id).add_child(footer_id);
552        }
553        let col_id = ctx.add(col);
554        let padded_id =
555            ctx.add(Padding::uniform(cal_recipe::CALENDAR_OUTER_PADDING * scale).child_id(col_id));
556
557        // Opaque background — Calendar can be used standalone (sits
558        // on whatever surface the parent provides) or as a popover
559        // overlay (anchored above arbitrary content). Without an
560        // explicit surface fill, the popover-mode calendar bleeds
561        // through to whatever's behind it. Use `SurfaceRole::Raised`
562        // because popovers are conventionally raised one elevation
563        // above the page surface; standalone usage on a `Panel`
564        // looks the same since both `Main` and `Raised` resolve to
565        // `surface_main` / `surface_raised` based on theme.
566        let bg_id = ctx.add(
567            crate::primitives::RectWidget::new()
568                .background(teksilo_tokens::SurfaceRole::Raised)
569                .border_color(teksilo_tokens::BorderRole::Default)
570                .border_width(theme.shape.border_width)
571                .corner_radius(teksilo_tokens::CornerRadius::uniform(
572                    theme.shape.radius_popup,
573                )),
574        );
575        let framed_id = ctx.add(
576            crate::primitives::ZStack::new()
577                .add_child(bg_id)
578                .add_child(padded_id),
579        );
580        self.root_child_id = Some(framed_id);
581
582        // Keyboard handler attaches at the root so it covers the whole
583        // calendar. Preview-pass so arrow keys are consumed before any
584        // descendant TextInputField sees them.
585        // Single keyboard handler on `on_key` (not `on_key_preview`).
586        // Bubble-pass routing covers both cases:
587        //   * grid root focused → on_key fires on the calendar (target)
588        //     → all keys handled, including Enter/Space → commit.
589        //   * chevron / today button focused → button's on_key fires
590        //     first; consumes Enter/Space (activates itself) and
591        //     stops bubbling. For arrows / PageUp / etc. the button
592        //     returns Ignored, so the event bubbles to the calendar
593        //     and navigates cells.
594        // This is the standard WAI-ARIA pattern: the focused widget
595        // gets first crack at the key, and the grid catches what's
596        // left. Using `on_key_preview` here breaks Enter/Space on
597        // descendant buttons because preview is consume-or-not, with
598        // no way to forward selectively.
599        let key_handler = build_keyboard_handler(
600            self.visible_month.clone(),
601            self.focused_date.clone(),
602            self.selection.clone(),
603            self.min_date,
604            self.max_date,
605            self.disabled_date_filter.clone(),
606            self.on_selection_changed.clone(),
607            self.on_range_changed.clone(),
608            self.on_activate.clone(),
609            self.on_month_changed.clone(),
610            enabled,
611            first_dow,
612        );
613
614        // Track keyboard focus on the calendar root so cells can render
615        // a roving-focus ring on the cell at `focused_date` only while
616        // the calendar actually holds focus (Int UI behaviour: no
617        // focus indicator on a non-focused control).
618        let focused_signal = self.focused.clone();
619        let handlers = HandlerSet::new()
620            .focusable(enabled)
621            .on_focus(move |has_focus, _ctx| {
622                focused_signal.set(has_focus);
623            })
624            .on_key(key_handler);
625        ctx.apply_self_handlers(handlers);
626
627        // Bind reactive sources at AccessibilityOnly so the AT node's
628        // `name` (visible_month → "Calendar, May 2026") and `value`
629        // (focused_date + selection) refresh as the user navigates,
630        // without forcing a layout/repaint.
631        let self_id = ctx.self_id();
632        let registry = ctx.binding_registry();
633        self.visible_month.bind_to(
634            self_id,
635            registry,
636            teksilo_core::binding::BindingLevel::AccessibilityOnly,
637        );
638        self.focused_date.bind_to(
639            self_id,
640            registry,
641            teksilo_core::binding::BindingLevel::AccessibilityOnly,
642        );
643        match &self.selection {
644            SelectionBinding::Single(sig) => sig.bind_to(
645                self_id,
646                registry,
647                teksilo_core::binding::BindingLevel::AccessibilityOnly,
648            ),
649            SelectionBinding::Range { value, .. } => value.bind_to(
650                self_id,
651                registry,
652                teksilo_core::binding::BindingLevel::AccessibilityOnly,
653            ),
654        }
655
656        vec![framed_id]
657    }
658
659    fn layout_response(
660        &self,
661        proposal: SizeProposal,
662        ctx: &LayoutContext,
663    ) -> teksilo_core::widget::LayoutResponse {
664        match self.root_child_id {
665            Some(id) => ctx
666                .child_size(id, proposal)
667                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
668            None => proposal.resolve(0.0, 0.0),
669        }
670        .into()
671    }
672
673    fn place_children(
674        &self,
675        bounds: Rect,
676        _proposal: SizeProposal,
677        children: &mut [WidgetPlacement],
678        _ctx: &LayoutContext,
679    ) {
680        for child in children.iter_mut() {
681            child.origin = bounds.origin();
682            child.size = bounds.size();
683        }
684    }
685
686    fn children(&self) -> Vec<WidgetId> {
687        self.root_child_id.into_iter().collect()
688    }
689
690    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
691        let ym = self.visible_month.get();
692        builder.set_role(Role::Grid);
693
694        let label = match &self.label {
695            Some(s) => s.resolve_now(),
696            None => {
697                let month_name = resolve_message_widget(month_long_key(ym.month()), &[]);
698                format!("Calendar, {} {}", month_name, ym.year())
699            }
700        };
701        builder.set_name(label);
702
703        // Live region: month-change AND roving-focus announcements
704        // both propagate by mutating `set_value`. The framework
705        // marks the node a11y-dirty when `visible_month` /
706        // `focused_date` / `selection` change (bindings registered
707        // in `build()` at `AccessibilityOnly` level), accessibility()
708        // re-runs, and AT picks up the new value as a polite
709        // announcement.
710        builder.set_live(Live::Polite);
711
712        // Compose the value: keyboard focus first (drives roving
713        // focus announcements), then the committed selection. ASCII
714        // " to " instead of an en-dash because some screen readers
715        // skip U+2013.
716        let focused = self.focused_date.get();
717        let focused_str = format!(
718            "{:04}-{:02}-{:02}",
719            focused.year(),
720            focused.month(),
721            focused.day()
722        );
723        let selection_str = match &self.selection {
724            SelectionBinding::Single(sig) => sig
725                .get()
726                .map(|d| format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day())),
727            SelectionBinding::Range { value, .. } => value.get().map(|r| {
728                format!(
729                    "{:04}-{:02}-{:02} to {:04}-{:02}-{:02}",
730                    r.start.year(),
731                    r.start.month(),
732                    r.start.day(),
733                    r.end.year(),
734                    r.end.month(),
735                    r.end.day(),
736                )
737            }),
738        };
739        let value_text = match selection_str {
740            Some(sel) => format!("{} (selected: {})", focused_str, sel),
741            None => focused_str,
742        };
743        builder.set_value(value_text);
744
745        // Framework a11y walker sets `set_disabled` from arena state.
746        builder.add_action(Action::Focus);
747    }
748}
749
750// ── Internal builders ─────────────────────────────────────────────────
751
752fn build_weekday_row(
753    ctx: &mut BuildContext,
754    first_dow: Weekday,
755    week_number_col_width: f32,
756) -> WidgetId {
757    // `week_number_col_width` already carries the text scale (computed by the
758    // caller). Apply the same scale to the local constants.
759    let scale = ctx.text_scale();
760    let mut row = HStack::new().spacing(cal_recipe::CALENDAR_CELL_GAP * scale);
761    if week_number_col_width > 0.0 {
762        // Empty corner cell above the week-number column.
763        let spacer = ctx.add(
764            FixedSize::new()
765                .width(week_number_col_width)
766                .height(cal_recipe::CALENDAR_WEEKDAY_ROW_HEIGHT * scale)
767                .child(Spacer::new()),
768        );
769        row = row.add_child(spacer);
770    }
771    let first_offset = first_dow.to_monday_zero_offset();
772    for i in 0..7 {
773        let dow = weekday_from_monday_zero(first_offset + i);
774        let key = weekday_short_key(dow);
775        let label = resolve_message_widget(key, &[]);
776        let long_label =
777            resolve_message_widget(crate::common::datetime::weekday_long_key(dow), &[]);
778        let text = TextWidget::new(lit!(label))
779            .style(TextStyleRole::Body)
780            .color(TextRole::Secondary)
781            .single_line()
782            .a11y_hidden();
783        let text_id = ctx.add(text);
784        let cell = WeekdayHeaderCell::new(
785            text_id,
786            long_label,
787            cal_recipe::CALENDAR_CELL_SIZE * scale,
788            cal_recipe::CALENDAR_WEEKDAY_ROW_HEIGHT * scale,
789        );
790        row = row.add_child(ctx.add(cell));
791    }
792    // AT: the row containing the column headers is itself a Row.
793    // WAI-ARIA grid pattern wants Row > ColumnHeader, not Group >
794    // ColumnHeader.
795    ctx.add(row.access_role(Role::Row))
796}
797
798struct BuildGridParams {
799    visible_month: Signal<YearMonth>,
800    focused_date: Signal<Date>,
801    focused: Signal<bool>,
802    selection: SelectionBinding,
803    first_dow: Weekday,
804    week_numbers: WeekNumberDisplay,
805    min_date: Option<Date>,
806    max_date: Option<Date>,
807    disabled_filter: Option<DisabledDateFilter>,
808    enabled: bool,
809    on_selection_changed: Option<OnSelectionChanged>,
810    on_range_changed: Option<OnRangeChanged>,
811    on_activate: Option<OnActivate>,
812    range_status: Signal<String>,
813}
814
815fn build_footer(
816    ctx: &mut BuildContext,
817    show_today: bool,
818    visible_month: Signal<YearMonth>,
819    focused_date: Signal<Date>,
820    selection: SelectionBinding,
821    on_selection_changed: Option<OnSelectionChanged>,
822    on_month_changed: Option<OnMonthChanged>,
823    range_status: Signal<String>,
824    is_range_mode: bool,
825) -> WidgetId {
826    let mut row = HStack::new().spacing(8.0);
827    if show_today {
828        let today_label = resolve_message_widget("calendar-button-today", &[]);
829        let cb_visible = visible_month.clone();
830        let cb_focused = focused_date.clone();
831        let cb_selection = selection.clone();
832        let cb_on_sel = on_selection_changed.clone();
833        let cb_on_month = on_month_changed.clone();
834        let today_btn = Button::new(lit!(today_label))
835            .variant(ButtonVariant::Filled)
836            .on_activate_fn(move |ctx_evt| {
837                let today = today_local();
838                let new_month = YearMonth::from_date(today);
839                if cb_visible.get() != new_month {
840                    cb_visible.set(new_month);
841                    if let Some(cb) = cb_on_month.as_ref() {
842                        cb(new_month, ctx_evt);
843                    }
844                }
845                cb_focused.set(today);
846                if let SelectionBinding::Single(sig) = &cb_selection
847                    && sig.get() != Some(today)
848                {
849                    sig.set(Some(today));
850                    if let Some(cb) = cb_on_sel.as_ref() {
851                        cb(Some(today), ctx_evt);
852                    }
853                }
854                ctx_evt.request_frame();
855            });
856        row = row.child(today_btn);
857    }
858    if is_range_mode {
859        let status_label = TextWidget::new(lit!(""))
860            .style(TextStyleRole::Body)
861            .color(TextRole::Secondary)
862            .text(range_status.clone())
863            .single_line()
864            .a11y_hidden();
865        let spacer = ctx.add(Spacer::new());
866        row = row.add_child(spacer).child(status_label);
867    } else {
868        row = row.child(Spacer::new());
869    }
870    ctx.add(row)
871}
872
873// ── Weekday header cell (per-cell a11y wrapper) ───────────────────────
874
875#[derive(Debug)]
876struct WeekdayHeaderCell {
877    child_id: WidgetId,
878    long_label: String,
879    cell_size: f32,
880    cell_height: f32,
881}
882
883impl WeekdayHeaderCell {
884    fn new(child_id: WidgetId, long_label: String, cell_size: f32, cell_height: f32) -> Self {
885        Self {
886            child_id,
887            long_label,
888            cell_size,
889            cell_height,
890        }
891    }
892}
893
894impl Widget for WeekdayHeaderCell {
895    fn layout_response(
896        &self,
897        _proposal: SizeProposal,
898        _ctx: &LayoutContext,
899    ) -> teksilo_core::widget::LayoutResponse {
900        Size::new(self.cell_size, self.cell_height).into()
901    }
902
903    fn place_children(
904        &self,
905        bounds: Rect,
906        _proposal: SizeProposal,
907        children: &mut [WidgetPlacement],
908        _ctx: &LayoutContext,
909    ) {
910        for child in children.iter_mut() {
911            child.origin = Point::new(bounds.x, bounds.y);
912            child.size = bounds.size();
913        }
914    }
915
916    fn children(&self) -> Vec<WidgetId> {
917        vec![self.child_id]
918    }
919
920    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
921        builder.set_role(Role::ColumnHeader);
922        builder.set_name(&self.long_label);
923    }
924}
925
926// ── CalendarBody — the 6×7 grid widget ────────────────────────────────
927
928struct CalendarBody {
929    params: BuildGridParams,
930    row_ids: RefCell<Vec<WidgetId>>,
931}
932
933impl std::fmt::Debug for CalendarBody {
934    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
935        f.debug_struct("CalendarBody").finish()
936    }
937}
938
939impl CalendarBody {
940    fn new(params: BuildGridParams) -> Self {
941        Self {
942            params,
943            row_ids: RefCell::new(Vec::new()),
944        }
945    }
946}
947
948impl Widget for CalendarBody {
949    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
950        // Compute the 6×7 grid once for the current visible month.
951        let ym = self.params.visible_month.get();
952        let first_of_month = ym.first_day();
953        let first_dow_offset = first_of_month.weekday().to_monday_zero_offset();
954        let target_first_offset = self.params.first_dow.to_monday_zero_offset();
955        // Days to step backward from the first of the month to land on
956        // the row's first day.
957        let lead = (first_dow_offset - target_first_offset).rem_euclid(7);
958        let grid_start = first_of_month
959            .checked_sub(jiff::Span::new().days(lead as i32 as i64))
960            .unwrap_or(first_of_month);
961
962        // Grow the grid with the global accessibility text scale. A scale
963        // change rebuilds the whole Calendar (the binding lives on the top-level
964        // widget), so reading it at build and multiplying here is sufficient.
965        let scale = ctx.text_scale();
966        let mut row_ids = Vec::with_capacity(6);
967        let cell_size = cal_recipe::CALENDAR_CELL_SIZE * scale;
968        let cell_height = cal_recipe::CALENDAR_CELL_SIZE * scale;
969        let gap = cal_recipe::CALENDAR_CELL_GAP * scale;
970        let week_number_col_width = match self.params.week_numbers {
971            WeekNumberDisplay::None => 0.0,
972            _ => cal_recipe::CALENDAR_WEEK_NUMBER_COLUMN_WIDTH * scale,
973        };
974
975        for week in 0..6 {
976            let mut row = HStack::new().spacing(gap);
977            if week_number_col_width > 0.0 {
978                // ISO week number = week containing the Thursday.
979                let week_first = grid_start
980                    .checked_add(jiff::Span::new().days((week * 7) as i64))
981                    .unwrap_or(grid_start);
982                let iso_wk = week_first
983                    .checked_add(jiff::Span::new().days(3i64))
984                    .unwrap_or(week_first)
985                    .iso_week_date();
986                let label_text = format!("{}", iso_wk.week());
987                let week_text = TextWidget::new(lit!(label_text))
988                    .style(TextStyleRole::Body)
989                    .color(TextRole::Secondary)
990                    .single_line()
991                    .a11y_hidden();
992                let week_text_id = ctx.add(week_text);
993                row = row.add_child(
994                    ctx.add(
995                        FixedSize::new()
996                            .width(week_number_col_width)
997                            .height(cell_height)
998                            .child(Center::new().child_id(week_text_id)),
999                    ),
1000                );
1001            }
1002            for day_idx in 0..7 {
1003                let day_offset = (week * 7 + day_idx) as i64;
1004                let day_date = grid_start
1005                    .checked_add(jiff::Span::new().days(day_offset))
1006                    .unwrap_or(grid_start);
1007                let cell = DayCell::new(
1008                    day_date,
1009                    self.params.visible_month.clone(),
1010                    self.params.focused_date.clone(),
1011                    self.params.focused.clone(),
1012                    self.params.selection.clone(),
1013                    cell_size,
1014                    self.params.min_date,
1015                    self.params.max_date,
1016                    self.params.disabled_filter.clone(),
1017                    self.params.enabled,
1018                    self.params.on_selection_changed.clone(),
1019                    self.params.on_range_changed.clone(),
1020                    self.params.on_activate.clone(),
1021                    self.params.range_status.clone(),
1022                );
1023                row = row.add_child(ctx.add(cell));
1024            }
1025            // AT: each week is a Role::Row; the WAI-ARIA grid pattern
1026            // expects Grid > Row > GridCell.
1027            row_ids.push(ctx.add(row.access_role(Role::Row)));
1028        }
1029        let mut col = VStack::new().spacing(gap);
1030        for id in &row_ids {
1031            col = col.add_child(*id);
1032        }
1033        let col_id = ctx.add(col);
1034        *self.row_ids.borrow_mut() = vec![col_id];
1035        // Bind `visible_month` at `Rebuild` level so navigating prev/
1036        // next month triggers a full re-`build()` of this widget,
1037        // regenerating the 42 DayCells with new dates. Relayout would
1038        // only re-measure existing cells, leaving them frozen on the
1039        // month they were constructed with.
1040        let self_id = ctx.self_id();
1041        self.params.visible_month.bind_to(
1042            self_id,
1043            ctx.binding_registry(),
1044            teksilo_core::binding::BindingLevel::Rebuild,
1045        );
1046        vec![col_id]
1047    }
1048
1049    fn layout_response(
1050        &self,
1051        proposal: SizeProposal,
1052        ctx: &LayoutContext,
1053    ) -> teksilo_core::widget::LayoutResponse {
1054        let row_ids = self.row_ids.borrow();
1055        match row_ids.first() {
1056            Some(id) => ctx
1057                .child_size(*id, proposal)
1058                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
1059            None => proposal.resolve(0.0, 0.0),
1060        }
1061        .into()
1062    }
1063
1064    fn place_children(
1065        &self,
1066        bounds: Rect,
1067        _proposal: SizeProposal,
1068        children: &mut [WidgetPlacement],
1069        _ctx: &LayoutContext,
1070    ) {
1071        for child in children.iter_mut() {
1072            child.origin = bounds.origin();
1073            child.size = bounds.size();
1074        }
1075    }
1076
1077    fn children(&self) -> Vec<WidgetId> {
1078        self.row_ids.borrow().clone()
1079    }
1080
1081    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1082        // Body itself is structural — the parent Calendar carries the
1083        // Role::Grid name. Hide this from AT so screen readers don't
1084        // double-announce.
1085        builder.set_role(Role::Group);
1086        builder.set_hidden();
1087    }
1088}
1089
1090// ── Keyboard handler factory ──────────────────────────────────────────
1091
1092fn build_keyboard_handler(
1093    visible_month: Signal<YearMonth>,
1094    focused_date: Signal<Date>,
1095    selection: SelectionBinding,
1096    min_date: Option<Date>,
1097    max_date: Option<Date>,
1098    disabled_filter: Option<DisabledDateFilter>,
1099    on_selection_changed: Option<OnSelectionChanged>,
1100    on_range_changed: Option<OnRangeChanged>,
1101    on_activate: Option<OnActivate>,
1102    on_month_changed: Option<OnMonthChanged>,
1103    enabled: bool,
1104    first_dow: Weekday,
1105) -> impl Fn(&WidgetEvent, &mut EventContext) -> EventResponse + 'static {
1106    let first_offset = first_dow.to_monday_zero_offset();
1107    move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
1108        if !enabled {
1109            return EventResponse::Ignored;
1110        }
1111        let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1112            return EventResponse::Ignored;
1113        };
1114        let cur = focused_date.get();
1115        let mut new_focus: Option<Date> = None;
1116        let mut new_visible: Option<YearMonth> = None;
1117        let mut commit: bool = false;
1118
1119        match key {
1120            Key::ArrowLeft => new_focus = step_focus(cur, -1),
1121            Key::ArrowRight => new_focus = step_focus(cur, 1),
1122            Key::ArrowUp => new_focus = step_focus(cur, -7),
1123            Key::ArrowDown => new_focus = step_focus(cur, 7),
1124            // Accelerator + Home / End (⌘ on macOS) jumps to the first / last
1125            // day of the month; plain Home / End stay within the week.
1126            Key::Home if modifiers.command() => {
1127                let ym = YearMonth::from_date(cur);
1128                new_focus = Some(ym.first_day());
1129            }
1130            Key::End if modifiers.command() => {
1131                let ym = YearMonth::from_date(cur);
1132                new_focus = Some(ym.last_day());
1133            }
1134            Key::Home => {
1135                let dow_offset = cur.weekday().to_monday_zero_offset();
1136                let lead = (dow_offset - first_offset).rem_euclid(7);
1137                new_focus = step_focus(cur, -(lead as i32));
1138            }
1139            Key::End => {
1140                let dow_offset = cur.weekday().to_monday_zero_offset();
1141                let lead = (dow_offset - first_offset).rem_euclid(7);
1142                new_focus = step_focus(cur, 6 - lead as i32);
1143            }
1144            Key::PageUp if modifiers.shift() => {
1145                let ym = YearMonth::from_date(cur).offset_months(-12);
1146                new_visible = Some(ym);
1147                new_focus = clamp_to_month(cur, ym);
1148            }
1149            Key::PageDown if modifiers.shift() => {
1150                let ym = YearMonth::from_date(cur).offset_months(12);
1151                new_visible = Some(ym);
1152                new_focus = clamp_to_month(cur, ym);
1153            }
1154            Key::PageUp => {
1155                let ym = YearMonth::from_date(cur).offset_months(-1);
1156                new_visible = Some(ym);
1157                new_focus = clamp_to_month(cur, ym);
1158            }
1159            Key::PageDown => {
1160                let ym = YearMonth::from_date(cur).offset_months(1);
1161                new_visible = Some(ym);
1162                new_focus = clamp_to_month(cur, ym);
1163            }
1164            Key::Enter | Key::Space => {
1165                commit = true;
1166            }
1167            Key::Escape => {
1168                if let SelectionBinding::Range { anchor, .. } = &selection
1169                    && anchor.get().is_some()
1170                {
1171                    anchor.set(None);
1172                    return EventResponse::Handled;
1173                }
1174                return EventResponse::Ignored;
1175            }
1176            Key::Character(c) if (*c == 't' || *c == 'T') => {
1177                let today = today_local();
1178                let ym = YearMonth::from_date(today);
1179                if visible_month.get() != ym {
1180                    visible_month.set(ym);
1181                    if let Some(cb) = on_month_changed.as_ref() {
1182                        cb(ym, ctx);
1183                    }
1184                }
1185                focused_date.set(today);
1186                ctx.request_frame();
1187                return EventResponse::Handled;
1188            }
1189            _ => return EventResponse::Ignored,
1190        }
1191
1192        if let Some(nf) = new_focus {
1193            // Clamp to min/max.
1194            let nf = match (min_date, max_date) {
1195                (Some(min), _) if nf < min => min,
1196                (_, Some(max)) if nf > max => max,
1197                _ => nf,
1198            };
1199            focused_date.set(nf);
1200            // If the new focus crosses out of the visible month, follow.
1201            let nfm = YearMonth::from_date(nf);
1202            if YearMonth::from_date(cur) != nfm && new_visible.is_none() {
1203                new_visible = Some(nfm);
1204            }
1205        }
1206        if let Some(nv) = new_visible
1207            && visible_month.get() != nv
1208        {
1209            visible_month.set(nv);
1210            if let Some(cb) = on_month_changed.as_ref() {
1211                cb(nv, ctx);
1212            }
1213        }
1214        if commit {
1215            let target = focused_date.get();
1216            if !is_date_disabled(target, min_date, max_date, disabled_filter.as_ref()) {
1217                commit_date(
1218                    target,
1219                    &selection,
1220                    on_selection_changed.as_ref(),
1221                    on_range_changed.as_ref(),
1222                    on_activate.as_ref(),
1223                    ctx,
1224                );
1225            }
1226        }
1227        ctx.request_frame();
1228        EventResponse::Handled
1229    }
1230}
1231
1232fn step_focus(cur: Date, days: i32) -> Option<Date> {
1233    cur.checked_add(jiff::Span::new().days(days as i64)).ok()
1234}
1235
1236fn clamp_to_month(cur: Date, ym: YearMonth) -> Option<Date> {
1237    let last = ym.last_day().day();
1238    let day = cur.day().min(last);
1239    Date::new(ym.year(), ym.month(), day).ok()
1240}
1241
1242pub(crate) fn is_date_disabled(
1243    d: Date,
1244    min: Option<Date>,
1245    max: Option<Date>,
1246    filter: Option<&DisabledDateFilter>,
1247) -> bool {
1248    if let Some(min) = min
1249        && d < min
1250    {
1251        return true;
1252    }
1253    if let Some(max) = max
1254        && d > max
1255    {
1256        return true;
1257    }
1258    if let Some(f) = filter
1259        && f(d)
1260    {
1261        return true;
1262    }
1263    false
1264}
1265
1266pub(crate) fn commit_date(
1267    d: Date,
1268    selection: &SelectionBinding,
1269    on_sel: Option<&OnSelectionChanged>,
1270    on_range: Option<&OnRangeChanged>,
1271    on_activate: Option<&OnActivate>,
1272    ctx: &mut EventContext,
1273) {
1274    match selection {
1275        SelectionBinding::Single(sig) => {
1276            sig.set(Some(d));
1277            if let Some(cb) = on_sel {
1278                cb(Some(d), ctx);
1279            }
1280            if let Some(cb) = on_activate {
1281                cb(d, ctx);
1282            }
1283        }
1284        SelectionBinding::Range { value, anchor } => {
1285            match anchor.get() {
1286                None => {
1287                    // First click: park the anchor; don't touch the
1288                    // committed `value` yet. Observers of `value`
1289                    // shouldn't see a transient one-day range.
1290                    // `on_selection_changed` fires to signal intent
1291                    // ("user clicked here, range pending"); the actual
1292                    // committed range arrives on the second click.
1293                    anchor.set(Some(d));
1294                    if let Some(cb) = on_sel {
1295                        cb(Some(d), ctx);
1296                    }
1297                }
1298                Some(start) => {
1299                    // Second click: build the range (DateRange::new
1300                    // swaps if end < start), drop the anchor, commit.
1301                    let range = DateRange::new(start, d);
1302                    anchor.set(None);
1303                    value.set(Some(range));
1304                    if let Some(cb) = on_range {
1305                        cb(Some(range), ctx);
1306                    }
1307                    if let Some(cb) = on_sel {
1308                        cb(Some(d), ctx);
1309                    }
1310                }
1311            }
1312        }
1313    }
1314}