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