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 `BorderRole::Focused`,
25//!   painted by the active `CalendarStyle::make_day_cell`.
26//! - **Out-of-month cells** (the leading days from the previous month
27//!   and trailing days from the next month that fill the 6×7 grid) are
28//!   rendered with `TextRole::Disabled` and remain selectable (matching
29//!   macOS / Material). To prevent selection use
30//!   `disabled_date_filter`.
31//! - **Keyboard** (matches the WAI-ARIA `grid` pattern):
32//!   - Arrow keys: move focus by one day.
33//!   - Home / End: first / last day of week.
34//!   - Ctrl+Home / Ctrl+End: first / last day of month.
35//!   - PageUp / PageDown: previous / next month.
36//!   - Shift+PageUp / Shift+PageDown: previous / next year.
37//!   - Enter / Space: commit focused day to selection.
38//!   - Escape: in range mode mid-selection, cancel anchor; otherwise
39//!     bubble (popover hosts close).
40//!   - `T`: jump focus to today.
41//! - **Keyboard in the months and years views**: the cursor is the month
42//!   (or year) shown, highlighted in the grid.
43//!   - Arrow keys: move by one month (year) across a row, by a row of three
44//!     up and down.
45//!   - PageUp / PageDown: a year back or on (a decade in the years view).
46//!   - Enter / Space: open the month under the cursor on its days (the year
47//!     on its months). Nothing is selected.
48//!   - Escape: back to the days of the month under the cursor. A date
49//!     field's popup takes Escape first and closes, and opens again on its
50//!     days.
51//!   - Activating the header title zooms out and takes the keyboard into the
52//!     grid, on the month (year) shown.
53//!
54//! # Accessibility
55//!
56//! Every string below is in the user's language. The words come from the
57//! framework's Fluent bundle, so an application that registers
58//! `framework_locales()` gets them translated; every date inside them is
59//! written by ICU for the widget tree's locale, in the locale's own order
60//! and grammar and on the Gregorian calendar the grid is laid out in, never
61//! assembled from numbers or from translated names (see
62//! `common::datetime::written`).
63//!
64//! - Container: `Role::Grid`, named after the visible month
65//!   ("Calendar, May 2026", "Calendrier, mai 2026"). It is **not** a live
66//!   region: a live grid announced its name and each weekday header as it
67//!   opened, and then focus said its name again. A change of month made
68//!   from the keyboard moves the focus to a day of the new month (see the
69//!   day cells below), whose name says the month. One made from a header
70//!   arrow or the Today button, where focus stays on the button, is
71//!   announced once through the tree's announcer: the title as it now
72//!   reads, or today's date in full. The value is the day under the
73//!   keyboard cursor in full, then the selection when there is one:
74//!   "Saturday, May 2, 2026 (selected: Friday, May 1, 2026)". With the
75//!   cursor on the one selected day, the day is said once: "Friday, May 1,
76//!   2026 (selected)". A range is joined by words ("… to …"), not an
77//!   en-dash, which some screen readers skip. The value is for a client
78//!   that reads the grid; the cursor is heard through the focus. UIA and
79//!   macOS raise a value-changed event on the grid, which is not the focus
80//!   while a day is; AT-SPI carries a string value on no interface.
81//! - Header arrow buttons: `Role::Button` with localized labels
82//!   ("Previous month", "Next month") and `Action::Click` advertised.
83//! - Header month/year label: `Role::Button`, a `Ghost` `Button` whose
84//!   activation demotes [`CalendarMode`] one level, swapping the body for
85//!   the coarser grid in place, and moves focus to the grid; no popup is
86//!   opened. Its name is its title: the month and year, the year, or the
87//!   decade in words ("2020 to 2029").
88//! - Month and year cells of the zoomed views: `Role::GridCell`, a month
89//!   named with its year ("mai 2026"), `Action::Click` advertised, no Tab
90//!   stop of their own: the grid names the one under the cursor as its
91//!   active descendant, as it does a day.
92//! - Weekday header row: `Role::Row` of `Role::ColumnHeader` cells, each
93//!   labelled with the long weekday name (e.g. "Monday").
94//! - Weeks: `Role::Row`, each holding its seven day cells, so the grid is
95//!   `Grid > Row > GridCell` in every platform's tree.
96//! - Day cells: `Role::GridCell` named by the day in full
97//!   ("Saturday, May 2, 2026", "samedi 2 mai 2026"), `set_selected`,
98//!   `set_aria_current(Date)` on today, `set_disabled` for filter
99//!   rejections, and `Action::Click` advertised. Keyboard focus stays on
100//!   the Calendar root, which names the cell under the cursor as its
101//!   active descendant while it holds focus in the day view. AccessKit
102//!   reports that cell as the focus on AT-SPI, UIA and macOS, so each
103//!   arrow press, and each change of month, is a focus change to the new
104//!   day, which is what a screen reader speaks.
105//!
106//! # Example
107//!
108//! ```ignore
109//! use teksilo::widgets::{Calendar, common::datetime::Date};
110//!
111//! let date = ctx.signal(Some(Date::constant(2026, 5, 2)));
112//! ctx.add(
113//!     Calendar::single(date.clone())
114//!         .show_today_button(true)
115//!         .on_selection_changed(|d, ctx| ctx.send_intent(MyIntent::DateChanged(d))),
116//! );
117//! ```
118//!
119//! ## Touch and pen
120//!
121//! A day cell is a **tap target**, not a manipulator, and the distinction
122//! decides what it declares. Its activation is `on_tap`, so it already lands on
123//! the release; its 32 dp box already clears the WCAG 2.2 SC 2.5.8 floor at
124//! Compact and follows the density ladder above it; and it produces no value
125//! from the press position and owns no drag. It therefore does **not** declare
126//! `touch_action(NONE)`: a finger that comes to rest on a day and then drags is
127//! scrolling the dialog or form the calendar sits in, which is what a user
128//! expects and what declaring NONE would forbid for nothing gained.
129
130mod cell;
131mod header;
132#[cfg(test)]
133mod tests;
134mod zoom_grid;
135
136use std::cell::RefCell;
137use std::rc::Rc;
138use teksilo_i18n::lit;
139
140use jiff::civil::Weekday;
141use teksilo_canvas::{Point, Rect, Size, SizeProposal};
142use teksilo_core::accessibility::AccessNodeBuilder;
143use teksilo_core::accesskit::{Action, Live, Role};
144use teksilo_core::build_context::BuildContext;
145use teksilo_core::event::{EventResponse, Key, WidgetEvent};
146use teksilo_core::signal::{Prop, Signal};
147use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
148use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
149use teksilo_core::widget_id::WidgetId;
150use teksilo_i18n::resolve_message_widget;
151use teksilo_tokens::{TextRole, TextStyleRole};
152
153use crate::button::{Button, ButtonVariant};
154use crate::common::datetime::Date;
155use crate::common::datetime::types::{YearMonth, today_local, weekday_from_monday_zero};
156use crate::common::datetime::weekday_short_key;
157use crate::common::datetime::written::{date_locale, full_date, medium_date, month_and_year};
158use crate::primitives::{Center, Divider, FixedSize, HStack, Padding, Spacer, TextWidget, VStack};
159use crate::styles::recipe_calendar_style as cal_recipe;
160
161use self::cell::DayCell;
162use self::header::CalendarHeader;
163use teksilo_i18n::{FluentValue, LanguageIdentifier, LocalizedString};
164
165// ── Public types ──────────────────────────────────────────────────────
166
167/// Inclusive range of two dates, with `start <= end` enforced at
168/// construction. Used by [`Calendar::range`].
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub struct DateRange {
171    pub start: Date,
172    pub end: Date,
173}
174
175impl DateRange {
176    /// Construct a range; swaps `start` and `end` if needed so the
177    /// invariant `start <= end` always holds.
178    pub fn new(a: Date, b: Date) -> Self {
179        if a <= b {
180            Self { start: a, end: b }
181        } else {
182            Self { start: b, end: a }
183        }
184    }
185
186    /// `true` iff `d` is between `start` and `end` inclusive.
187    pub fn contains(&self, d: Date) -> bool {
188        d >= self.start && d <= self.end
189    }
190}
191
192/// Selection mode discriminant — chosen at construction by picking
193/// between [`Calendar::single`] and [`Calendar::range`]. Stored
194/// internally; not part of the public surface.
195#[derive(Clone)]
196pub(crate) enum SelectionBinding {
197    Single(Signal<Option<Date>>),
198    Range {
199        value: Signal<Option<DateRange>>,
200        anchor: Signal<Option<Date>>,
201    },
202}
203
204/// What the calendar body is showing — drives the WPF/Avalonia
205/// "header-zoom" UX where clicking the title cycles to a coarser
206/// grid, letting the user reach any year in 2-3 clicks instead of
207/// many chevron presses. Default [`CalendarMode::Days`].
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
209pub enum CalendarMode {
210    /// 6×7 day grid for the visible month. Title shows "May 2026".
211    /// Header chevrons step by ±1 month and ±1 year.
212    #[default]
213    Days,
214    /// 4×3 grid of months. Title shows "2026". Header chevrons step
215    /// by ±1 year. Picking a cell zooms back into [`Self::Days`].
216    Months,
217    /// 4×3 grid of years (current decade). Title shows "2020 to 2029".
218    /// Header chevrons step by ±10 years (one decade). Picking a cell
219    /// zooms back into [`Self::Months`].
220    Years,
221}
222
223impl CalendarMode {
224    /// Mode after demoting one level (clicking the header title).
225    /// `Years` is the coarsest level — no further demotion.
226    pub fn demote(self) -> Self {
227        match self {
228            Self::Days => Self::Months,
229            Self::Months => Self::Years,
230            Self::Years => Self::Years,
231        }
232    }
233}
234
235/// Whether and how week numbers are displayed in the leading column of the day grid.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237pub enum WeekNumberDisplay {
238    /// No week-number column (default).
239    #[default]
240    None,
241    /// ISO 8601 week number — week 1 is the week containing the first
242    /// Thursday of the year. Adds a narrow column to the left of the day grid.
243    Iso8601,
244}
245
246// ── Builder API ───────────────────────────────────────────────────────
247
248pub(crate) type DisabledDateFilter = Rc<dyn Fn(Date) -> bool>;
249pub(crate) type OnSelectionChanged = Rc<dyn Fn(Option<Date>, &mut EventContext)>;
250pub(crate) type OnRangeChanged = Rc<dyn Fn(Option<DateRange>, &mut EventContext)>;
251pub(crate) type OnMonthChanged = Rc<dyn Fn(YearMonth, &mut EventContext)>;
252pub(crate) type OnActivate = Rc<dyn Fn(Date, &mut EventContext)>;
253/// The day grid's cells with their dates, shared between the grid that
254/// builds them and the calendar root that names one to assistive technology.
255type DayCells = Rc<RefCell<Vec<(Date, WidgetId)>>>;
256
257/// Standalone month-grid date picker. See the [module docs](self) for
258/// the full feature list and a usage example.
259pub struct Calendar {
260    selection: SelectionBinding,
261    visible_month: Signal<YearMonth>,
262    focused_date: Signal<Date>,
263    /// Body mode (Days / Months / Years). Owned so the header label
264    /// can demote it on click and the cells can promote it back
265    /// (Years cell → Months → Days). Default [`CalendarMode::Days`].
266    mode: Signal<CalendarMode>,
267    /// Optional custom override of the locale-derived first day of week.
268    first_day_of_week_override: Option<Weekday>,
269    week_numbers: WeekNumberDisplay,
270    show_today_button: bool,
271    show_navigation: bool,
272    min_date: Option<Date>,
273    max_date: Option<Date>,
274    disabled_date_filter: Option<DisabledDateFilter>,
275    label: Option<LocalizedString>,
276    /// Enabled state, static or reactive. Forwarded to the arena at
277    /// build time.
278    enabled: Prop<bool>,
279    on_selection_changed: Option<OnSelectionChanged>,
280    on_range_changed: Option<OnRangeChanged>,
281    on_month_changed: Option<OnMonthChanged>,
282    on_activate: Option<OnActivate>,
283    /// The locale every date the calendar writes is written in, resolved
284    /// from the tree's locale in `build()`. Read again by `accessibility()`,
285    /// which has no context of its own; a locale switch rebuilds the
286    /// calendar (the binding sits at `Rebuild`), so it cannot go stale.
287    lang: LanguageIdentifier,
288    /// `true` while the Calendar root holds keyboard focus. Drives the
289    /// roving-focus ring on the cell at `focused_date` so keyboard
290    /// users see where the next arrow key will land, and whether the
291    /// root names that cell as its active descendant. Written by
292    /// `.on_focus()` in `build()`.
293    focused: Signal<bool>,
294    /// The day cells of the grid built last, each with its date, rewritten
295    /// by every build of the day grid. `accessibility()` finds the cell
296    /// under the cursor here to name it as the active descendant.
297    day_cells: DayCells,
298    /// The months view's cells, by month number, and the years view's, by
299    /// year: where `accessibility()` finds the one under the cursor there.
300    month_cells: zoom_grid::ZoomCells,
301    year_cells: zoom_grid::ZoomCells,
302    // Build state
303    root_child_id: Option<WidgetId>,
304}
305
306impl std::fmt::Debug for Calendar {
307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308        f.debug_struct("Calendar")
309            .field("enabled", &self.enabled.get())
310            .finish_non_exhaustive()
311    }
312}
313
314impl Calendar {
315    /// Construct a calendar in single-selection mode bound to a
316    /// nullable date signal.
317    pub fn single(value: Signal<Option<Date>>) -> Self {
318        let initial = value.get().unwrap_or_else(today_local);
319        Self::new(SelectionBinding::Single(value), initial)
320    }
321
322    /// Construct a calendar in range-selection mode bound to a
323    /// nullable date-range signal.
324    pub fn range(value: Signal<Option<DateRange>>) -> Self {
325        let initial = value.get().map(|r| r.start).unwrap_or_else(today_local);
326        let anchor = Signal::new(None);
327        Self::new(SelectionBinding::Range { value, anchor }, initial)
328    }
329
330    fn new(selection: SelectionBinding, initial_focus: Date) -> Self {
331        Self {
332            selection,
333            visible_month: Signal::new(YearMonth::from_date(initial_focus)),
334            focused_date: Signal::new(initial_focus),
335            mode: Signal::new(CalendarMode::default()),
336            first_day_of_week_override: None,
337            week_numbers: WeekNumberDisplay::None,
338            show_today_button: false,
339            show_navigation: true,
340            min_date: None,
341            max_date: None,
342            disabled_date_filter: None,
343            label: None,
344            enabled: Prop::Static(true),
345            on_selection_changed: None,
346            on_range_changed: None,
347            on_month_changed: None,
348            on_activate: None,
349            lang: date_locale(None),
350            focused: Signal::new(false),
351            day_cells: DayCells::default(),
352            month_cells: zoom_grid::ZoomCells::default(),
353            year_cells: zoom_grid::ZoomCells::default(),
354            root_child_id: None,
355        }
356    }
357
358    /// Override the locale-derived first day of the week.
359    pub fn first_day_of_week(mut self, w: Weekday) -> Self {
360        self.first_day_of_week_override = Some(w);
361        self
362    }
363
364    /// Show or hide the leading week-number column.
365    pub fn week_numbers(mut self, mode: WeekNumberDisplay) -> Self {
366        self.week_numbers = mode;
367        self
368    }
369
370    /// Show a "Today" button in the footer that jumps focus and selection
371    /// (in single mode) to today.
372    pub fn show_today_button(mut self, show: bool) -> Self {
373        self.show_today_button = show;
374        self
375    }
376
377    /// Show or hide the prev/next month navigation arrows.
378    pub fn show_navigation(mut self, show: bool) -> Self {
379        self.show_navigation = show;
380        self
381    }
382
383    /// Earliest allowed date; days before this read as disabled.
384    pub fn min_date(mut self, d: Date) -> Self {
385        self.min_date = Some(d);
386        self
387    }
388
389    /// Latest allowed date; days after this read as disabled.
390    pub fn max_date(mut self, d: Date) -> Self {
391        self.max_date = Some(d);
392        self
393    }
394
395    /// Per-cell predicate. `true` ⇒ cell is disabled (no click, no
396    /// keyboard commit, AT marks `disabled`).
397    pub fn disabled_date_filter(mut self, f: impl Fn(Date) -> bool + 'static) -> Self {
398        self.disabled_date_filter = Some(Rc::new(f));
399        self
400    }
401
402    /// Override the AT label. Default: "Calendar, May 2026" (localized,
403    /// derived from the visible month).
404    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
405        let ls: LocalizedString = label.into();
406        self.label = Some(ls);
407        self
408    }
409
410    /// Set the enabled state, statically or reactively. Forwarded to the
411    /// arena at build time — a bound `Signal<bool>` updates live.
412    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
413        self.enabled = enabled.into();
414        self
415    }
416
417    /// Fired when the selection changes. In range mode use
418    /// [`on_range_changed`](Self::on_range_changed) instead — this
419    /// callback fires on every committed-day change in range mode too,
420    /// passing the just-committed endpoint.
421    pub fn on_selection_changed(
422        mut self,
423        f: impl Fn(Option<Date>, &mut EventContext) + 'static,
424    ) -> Self {
425        self.on_selection_changed = Some(Rc::new(f));
426        self
427    }
428
429    /// Fired in range mode whenever a range is committed (second click
430    /// of the pair). `None` fires when the user resets via Escape or
431    /// when the bound value is externally cleared.
432    pub fn on_range_changed(
433        mut self,
434        f: impl Fn(Option<DateRange>, &mut EventContext) + 'static,
435    ) -> Self {
436        self.on_range_changed = Some(Rc::new(f));
437        self
438    }
439
440    /// Fired when the visible month changes (navigation arrows,
441    /// keyboard PageUp/Down, today jump).
442    pub fn on_month_changed(mut self, f: impl Fn(YearMonth, &mut EventContext) + 'static) -> Self {
443        self.on_month_changed = Some(Rc::new(f));
444        self
445    }
446
447    /// Fired in single mode on Enter or click (i.e. when the user
448    /// "double commits"). Distinct from selection change; popover hosts
449    /// use this to dismiss themselves only on a real click, not on
450    /// keyboard navigation.
451    pub fn on_activate(mut self, f: impl Fn(Date, &mut EventContext) + 'static) -> Self {
452        self.on_activate = Some(Rc::new(f));
453        self
454    }
455
456    /// Reactive accessor for the currently-visible month.
457    pub fn visible_month_signal(&self) -> Signal<YearMonth> {
458        self.visible_month.clone()
459    }
460
461    /// Reactive accessor for the focused-cell date.
462    pub fn focused_date_signal(&self) -> Signal<Date> {
463        self.focused_date.clone()
464    }
465
466    /// Reactive accessor for the body mode (Days / Months / Years).
467    /// Drives the header-zoom UX. Apps can read this to react to mode
468    /// changes, or write to it to programmatically zoom in/out.
469    pub fn mode_signal(&self) -> Signal<CalendarMode> {
470        self.mode.clone()
471    }
472
473    /// What a host that shows this calendar in a popup sets on each opening.
474    /// Take it once the date bounds are set: they clamp the day it opens on.
475    pub(crate) fn opening(&self) -> CalendarOpening {
476        CalendarOpening {
477            visible_month: self.visible_month.clone(),
478            focused_date: self.focused_date.clone(),
479            mode: self.mode.clone(),
480            anchor: match &self.selection {
481                SelectionBinding::Single(_) => None,
482                SelectionBinding::Range { anchor, .. } => Some(anchor.clone()),
483            },
484            min_date: self.min_date,
485            max_date: self.max_date,
486        }
487    }
488}
489
490/// The part of a calendar's state a date field puts back each time it opens
491/// the calendar in its popup.
492///
493/// A date field builds its calendar once and keeps it between openings, and
494/// with it the day under the cursor, the month shown and the view. Left
495/// alone, an opening shows wherever the last one was left, which is not the
496/// date the field holds: a step taken in the field, or a cursor moved and
497/// abandoned with Escape, and the calendar opens on another day, which Enter
498/// then writes over the field's value. Closed from the months view, it
499/// reopens on the months, with no day to pick. A range calendar closed with
500/// one end picked (Escape closes the popup before the calendar can drop that
501/// end) kept it, and one Enter in the next opening wrote a range from that
502/// abandoned day to the cursor over the field's.
503#[derive(Clone)]
504pub(crate) struct CalendarOpening {
505    visible_month: Signal<YearMonth>,
506    focused_date: Signal<Date>,
507    mode: Signal<CalendarMode>,
508    /// A range calendar's first end, picked and waiting for the other.
509    anchor: Option<Signal<Option<Date>>>,
510    min_date: Option<Date>,
511    max_date: Option<Date>,
512}
513
514impl CalendarOpening {
515    /// Show the days of `date`'s month with the cursor on `date`, or on today
516    /// when the field holds no date, kept within the calendar's bounds, with
517    /// no range begun.
518    pub(crate) fn open_on(&self, date: Option<Date>) {
519        if let Some(anchor) = &self.anchor
520            && anchor.get().is_some()
521        {
522            anchor.set(None);
523        }
524        let mut day = date.unwrap_or_else(today_local);
525        if let Some(min) = self.min_date {
526            day = day.max(min);
527        }
528        if let Some(max) = self.max_date {
529            day = day.min(max);
530        }
531        if self.mode.get() != CalendarMode::Days {
532            self.mode.set(CalendarMode::Days);
533        }
534        let month = YearMonth::from_date(day);
535        if self.visible_month.get() != month {
536            self.visible_month.set(month);
537        }
538        if self.focused_date.get() != day {
539            self.focused_date.set(day);
540        }
541    }
542}
543
544impl Widget for Calendar {
545    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
546        let theme = ctx.theme_signal().get();
547        let self_id = ctx.self_id();
548        // Global accessibility text scale: the calendar's cell/header sizes are
549        // fixed constants read at build, so a scale change must *rebuild* (a
550        // relayout won't recompute them). Bind the scale signal at `Rebuild`
551        // level — exactly like `visible_month` — and multiply every dimension
552        // constant by `scale` below. Rebuilding the Calendar reconstructs its
553        // header / weekday row / body, so they all pick up the new scale.
554        let scale = ctx.text_scale();
555        ctx.text_scale_signal().bind_to(
556            self_id,
557            ctx.binding_registry(),
558            teksilo_core::binding::BindingLevel::Rebuild,
559        );
560        // Forward the enabled state into the arena. After this point the
561        // arena is the single source of truth.
562        ctx.enabled_when(self_id, self.enabled.clone());
563        // Inner cell/grid helpers still take an `enabled: bool`
564        // snapshot which is fine for build-time decisions (they pass
565        // it to the inner widgets which now consult the arena).
566        let enabled = self.enabled.get();
567        let week_numbers = self.week_numbers;
568        let week_number_col_width = match week_numbers {
569            WeekNumberDisplay::None => 0.0,
570            _ => cal_recipe::CALENDAR_WEEK_NUMBER_COLUMN_WIDTH * scale,
571        };
572
573        // A locale switch must re-derive the first day of week: it is read from
574        // `ctx.locale_signal()` at build time, and `WidgetTree::set_locale`
575        // only calls `mark_all_dirty` (layout + paint), which never re-runs
576        // `build()`. Without this binding the widget keeps rendering with
577        // the pattern of whatever locale was active when it was first
578        // built. Bound at `Rebuild` for the same reason `Calendar` binds
579        // the text scale there — the value is a build-time constant, so a
580        // relayout cannot pick it up.
581        ctx.locale_signal().bind_to(
582            ctx.self_id(),
583            ctx.binding_registry(),
584            teksilo_core::binding::BindingLevel::Rebuild,
585        );
586
587        // Resolve first day of week: explicit override → locale default → Monday.
588        let tree_locale = ctx.locale_signal().get();
589        let first_dow = self.first_day_of_week_override.unwrap_or_else(|| {
590            crate::common::datetime::first_day_of_week_for_locale(
591                tree_locale.as_deref().unwrap_or_default(),
592            )
593        });
594        self.lang = date_locale(tree_locale.as_deref());
595
596        // ── Header (prev / month-label / next) ──────────────────
597        let header_id = if self.show_navigation {
598            ctx.add(CalendarHeader::new(
599                self.visible_month.clone(),
600                self.focused_date.clone(),
601                self.mode.clone(),
602                self.on_month_changed.clone(),
603                self.lang.clone(),
604                self.focused.clone(),
605                self_id,
606            ))
607        } else {
608            // Empty placeholder so layout shape stays consistent.
609            ctx.add(FixedSize::new().width(0.0).height(0.0).child(Spacer::new()))
610        };
611
612        // ── Weekday header row ──────────────────────────────────
613        // Only meaningful in Days mode; hidden in Months/Years zoom.
614        let weekday_row_id = build_weekday_row(ctx, first_dow, week_number_col_width);
615        ctx.visible_when(
616            weekday_row_id,
617            self.mode.map(|m| matches!(m, CalendarMode::Days)),
618        );
619
620        // ── Body Switcher: Days / Months / Years ────────────────
621        // The mode signal drives a Switcher that mounts only the
622        // currently-active body. Day grid keeps all its existing
623        // wiring; the two zoom grids are minimal click-driven 4×3
624        // pickers that promote the visible_month and zoom back in
625        // when a cell is picked.
626        let day_body = self::CalendarBody::new(BuildGridParams {
627            visible_month: self.visible_month.clone(),
628            focused_date: self.focused_date.clone(),
629            focused: self.focused.clone(),
630            selection: self.selection.clone(),
631            first_dow,
632            week_numbers,
633            min_date: self.min_date,
634            max_date: self.max_date,
635            disabled_filter: self.disabled_date_filter.clone(),
636            enabled,
637            on_selection_changed: self.on_selection_changed.clone(),
638            on_range_changed: self.on_range_changed.clone(),
639            on_activate: self.on_activate.clone(),
640            lang: self.lang.clone(),
641            day_cells: self.day_cells.clone(),
642        });
643        // Cell footprint for zoom modes derived from day grid cell
644        // size so the body's overall width matches the day grid (7
645        // day cells worth, divided across 3 zoom columns) and the
646        // calendar's outer width stays constant across mode flips.
647        let zoom_cell_height = (cal_recipe::CALENDAR_CELL_SIZE * 1.4).max(36.0) * scale;
648        let zoom_cell_width = (cal_recipe::CALENDAR_CELL_SIZE * 7.0 / 3.0).max(64.0) * scale;
649        let zoom = zoom_grid::Zoom {
650            visible_month: self.visible_month.clone(),
651            focused_date: self.focused_date.clone(),
652            mode: self.mode.clone(),
653        };
654        let months_body = zoom_grid::MonthsGrid::new(
655            zoom.clone(),
656            self.month_cells.clone(),
657            self.lang.clone(),
658            enabled,
659            zoom_cell_width,
660            zoom_cell_height,
661        );
662        let years_body = zoom_grid::YearsGrid::new(
663            zoom,
664            self.year_cells.clone(),
665            enabled,
666            zoom_cell_width,
667            zoom_cell_height,
668        );
669        let mode_index = self.mode.map(|m| match m {
670            CalendarMode::Days => 0_usize,
671            CalendarMode::Months => 1,
672            CalendarMode::Years => 2,
673        });
674        let grid_id = ctx.add(
675            crate::primitives::Switcher::new(mode_index)
676                .child(day_body)
677                .child(months_body)
678                .child(years_body),
679        );
680
681        // ── Optional footer ─────────────────────────────────────
682        let footer_id =
683            if self.show_today_button || matches!(self.selection, SelectionBinding::Range { .. }) {
684                Some(build_footer(
685                    ctx,
686                    self.show_today_button,
687                    self.visible_month.clone(),
688                    self.focused_date.clone(),
689                    self.focused.clone(),
690                    self.selection.clone(),
691                    self.on_selection_changed.clone(),
692                    self.on_month_changed.clone(),
693                    &self.lang,
694                ))
695            } else {
696                None
697            };
698
699        // ── Assemble VStack ─────────────────────────────────────
700        let mut col = VStack::new()
701            .spacing(cal_recipe::CALENDAR_SECTION_GAP * scale)
702            .child(header_id)
703            .child(weekday_row_id)
704            .child(grid_id);
705        if let Some(footer_id) = footer_id {
706            let divider_id = ctx.add(Divider::horizontal());
707            col = col.child(divider_id).child(footer_id);
708        }
709        let col_id = ctx.add(col);
710        let padded_id =
711            ctx.add(Padding::uniform(cal_recipe::CALENDAR_OUTER_PADDING * scale).child(col_id));
712
713        // Opaque background — Calendar can be used standalone (sits
714        // on whatever surface the parent provides) or as a popover
715        // overlay (anchored above arbitrary content). Without an
716        // explicit surface fill, the popover-mode calendar bleeds
717        // through to whatever's behind it. Use `SurfaceRole::Raised`
718        // because popovers are conventionally raised one elevation
719        // above the page surface; standalone usage on a `Panel`
720        // looks the same since both `Main` and `Raised` resolve to
721        // `surface_main` / `surface_raised` based on theme.
722        let bg_id = ctx.add(
723            crate::primitives::RectWidget::new()
724                .background(teksilo_tokens::SurfaceRole::Raised)
725                .border_color(teksilo_tokens::BorderRole::Default)
726                .border_width(theme.shape.border_width)
727                .corner_radius(teksilo_tokens::CornerRadius::uniform(
728                    theme.shape.radius_popup,
729                )),
730        );
731        // `Live::Off` on the one root everything else hangs from. The grid
732        // is not a live region (see `accessibility`), but an application
733        // can put the calendar inside one of its own, and
734        // `accesskit_consumer` hands a node's politeness down to every
735        // descendant that sets none (node.rs:906-910). The AT-SPI, UIA and
736        // macOS adapters announce every named node that enters the
737        // filtered tree, or is renamed there, with an inherited politeness
738        // other than off (atspi_common adapter.rs:71-77 and node.rs:610-622,
739        // windows adapter.rs:255-263 and 313-324, macos event.rs:236-241
740        // and 300-310). Inside such a region, opening the calendar would
741        // announce all 42 dates, the weekdays and the header buttons, and a
742        // change of month every renamed day, in whatever order the
743        // consumer's hash set gave. Orca speaks each announcement with
744        // interrupt set (default.py `speakMessage`), so the last one would
745        // win: a date picked by the hash. With the Off here, the region
746        // hears the grid's name and nothing under it. The node stays a
747        // `GenericContainer`, which every adapter drops with its children
748        // promoted into the grid.
749        let framed_id = ctx.add(
750            crate::primitives::ZStack::new()
751                .child(bg_id)
752                .child(padded_id)
753                .access_live(Live::Off),
754        );
755        self.root_child_id = Some(framed_id);
756
757        // Keyboard handler attaches at the root so it covers the whole
758        // calendar.
759        // Single keyboard handler on `on_key` (not `on_key_preview`).
760        // Bubble-pass routing covers both cases:
761        //   * grid root focused → on_key fires on the calendar (target)
762        //     → all keys handled, including Enter/Space → commit.
763        //   * chevron / today button focused → button's on_key fires
764        //     first; consumes Enter/Space (activates itself) and
765        //     stops bubbling. For arrows / PageUp / etc. the button
766        //     returns Ignored, so the event bubbles to the calendar
767        //     and navigates cells.
768        // This is the standard WAI-ARIA pattern: the focused widget
769        // gets first crack at the key, and the grid catches what's
770        // left. Using `on_key_preview` here breaks Enter/Space on
771        // descendant buttons because preview is consume-or-not, with
772        // no way to forward selectively.
773        let key_handler = build_keyboard_handler(
774            self.visible_month.clone(),
775            self.focused_date.clone(),
776            self.mode.clone(),
777            self.selection.clone(),
778            self.min_date,
779            self.max_date,
780            self.disabled_date_filter.clone(),
781            self.on_selection_changed.clone(),
782            self.on_range_changed.clone(),
783            self.on_activate.clone(),
784            self.on_month_changed.clone(),
785            enabled,
786            first_dow,
787        );
788
789        // Track keyboard focus on the calendar root so cells can render
790        // a roving-focus ring on the cell at `focused_date` only while
791        // the calendar actually holds focus (Int UI behaviour: no
792        // focus indicator on a non-focused control).
793        let focused_signal = self.focused.clone();
794        let handlers = HandlerSet::new()
795            .focusable(enabled)
796            .on_focus(move |has_focus, _ctx| {
797                focused_signal.set(has_focus);
798            })
799            .on_key(key_handler);
800        ctx.apply_self_handlers(handlers);
801
802        // Bind reactive sources at AccessibilityOnly so the AT node's
803        // `name` (visible_month → "Calendar, May 2026"), `value`
804        // (focused_date + selection) and active descendant refresh as the
805        // user navigates, without forcing a layout/repaint.
806        let self_id = ctx.self_id();
807        let registry = ctx.binding_registry();
808        self.visible_month.bind_to(
809            self_id,
810            registry,
811            teksilo_core::binding::BindingLevel::AccessibilityOnly,
812        );
813        self.focused_date.bind_to(
814            self_id,
815            registry,
816            teksilo_core::binding::BindingLevel::AccessibilityOnly,
817        );
818        // Focus and the view decide whether a day is named as the active
819        // descendant, so both re-walk the node too.
820        self.focused.bind_to(
821            self_id,
822            registry,
823            teksilo_core::binding::BindingLevel::AccessibilityOnly,
824        );
825        self.mode.bind_to(
826            self_id,
827            registry,
828            teksilo_core::binding::BindingLevel::AccessibilityOnly,
829        );
830        match &self.selection {
831            SelectionBinding::Single(sig) => sig.bind_to(
832                self_id,
833                registry,
834                teksilo_core::binding::BindingLevel::AccessibilityOnly,
835            ),
836            SelectionBinding::Range { value, .. } => value.bind_to(
837                self_id,
838                registry,
839                teksilo_core::binding::BindingLevel::AccessibilityOnly,
840            ),
841        }
842
843        vec![framed_id]
844    }
845
846    fn layout_response(
847        &self,
848        proposal: SizeProposal,
849        ctx: &LayoutContext,
850    ) -> teksilo_core::widget::LayoutResponse {
851        match self.root_child_id {
852            Some(id) => ctx
853                .child_size(id, proposal)
854                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
855            None => proposal.resolve(0.0, 0.0),
856        }
857        .into()
858    }
859
860    fn place_children(
861        &self,
862        bounds: Rect,
863        _proposal: SizeProposal,
864        children: &mut [WidgetPlacement],
865        _ctx: &LayoutContext,
866    ) {
867        for child in children.iter_mut() {
868            child.origin = bounds.origin();
869            child.size = bounds.size();
870        }
871    }
872
873    fn children(&self) -> Vec<WidgetId> {
874        self.root_child_id.into_iter().collect()
875    }
876
877    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
878        let ym = self.visible_month.get();
879        builder.set_role(Role::Grid);
880
881        let label = match &self.label {
882            Some(s) => s.resolve_now(),
883            None => resolve_message_widget(
884                "calendar-name-with-month",
885                &[("month", FluentValue::from(month_and_year(ym, &self.lang)))],
886            ),
887        };
888        builder.set_name(label);
889
890        // Deliberately not a live region. A live node speaks its name when it
891        // enters the tree and whenever the name changes (AT-SPI:
892        // `accesskit_atspi_common` `adapter.rs:72-77`, `node.rs:610-622`;
893        // UIA and macOS alike), and every descendant inherits the setting
894        // (`accesskit_consumer` `NodeRef::live`). So a live grid said
895        // everything twice: opening it announced its name, then its seven
896        // weekday headers, then focus said its name again; a change of month
897        // from the keyboard was an announcement plus the rename of the node
898        // Orca's focus was on, which Orca also speaks; and each header button
899        // Tab reached was announced as it entered the tree and again as it
900        // took focus. A change of month made from a header button, where
901        // focus is not on the grid, is announced by that button instead (see
902        // `header.rs`).
903        //
904        // The bindings registered in `build()` at `AccessibilityOnly` re-run
905        // this on every change of month, cursor, selection, focus or view.
906
907        // The day under the cursor, as the grid's active descendant.
908        //
909        // Keyboard focus stays here, on the grid, and the arrow keys move a
910        // cursor through its days. A screen reader is told about that cursor
911        // only if it becomes a focus change. `accesskit_consumer` resolves the
912        // platform's focus as `focused.active_descendant().unwrap_or(focused)`
913        // (`tree.rs:537-543`) and reports the node that comes out, so naming
914        // the day here makes every arrow press a focus change to that day on
915        // all three platforms: AT-SPI `state-changed:focused`
916        // (`accesskit_atspi_common` `adapter.rs:324-340`), which Orca speaks
917        // as its new locus of focus; UIA's focus-changed event
918        // (`accesskit_windows` `adapter.rs:341-345`), which is also the only
919        // way UIA has to say it, having no active-descendant property of its
920        // own; and macOS's `FocusedUIElementChanged` (`accesskit_macos`
921        // `event.rs:319-326`). A change of month is one too, to a day whose
922        // name says the month.
923        //
924        // Before this, Orca heard nothing at all as the cursor moved: the
925        // grid's name changes only with the month, and its value, where the
926        // cursor was, reaches no AT-SPI interface. It is `ListView`'s current
927        // row again (see `list_view/widget_impl.rs`), and gated the same way:
928        // only while the grid holds focus, since a container without focus
929        // has no descendant to speak of. A cursor on a day the grid did not
930        // build names nothing, and the grid stays the focus.
931        //
932        // The months and years views name their cell under the cursor the
933        // same way: the month or year shown. They named nothing before, and
934        // the grid itself took focus there, so a reader zoomed out onto a
935        // view where no key they pressed was ever heard.
936        let under_cursor = match self.mode.get() {
937            CalendarMode::Days => {
938                let cursor = self.focused_date.get();
939                self.day_cells
940                    .borrow()
941                    .iter()
942                    .find(|(date, _)| *date == cursor)
943                    .map(|(_, cell)| *cell)
944            }
945            CalendarMode::Months => {
946                zoom_grid::cell_for(&self.month_cells, self.visible_month.get().month().into())
947            }
948            CalendarMode::Years => {
949                zoom_grid::cell_for(&self.year_cells, self.visible_month.get().year())
950            }
951        };
952        if self.focused.get()
953            && let Some(cell) = under_cursor
954        {
955            builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(cell));
956        }
957
958        // Compose the value: keyboard focus first, then the committed
959        // selection, every date in full. It is no longer how the cursor is
960        // heard, now that the day under it takes the platform's focus: UIA
961        // and macOS raise its value-changed event on the grid, which is not
962        // that focus any more, and AT-SPI carries a string value on no
963        // interface. It stays for a client that reads the grid; the month
964        // and year views name their cell under the cursor as the focus too.
965        // The words around the dates are the framework's messages, and a
966        // range is joined by words (`calendar-date-range`), not an en-dash
967        // some readers skip.
968        let cursor = self.focused_date.get();
969        let focused = full_date(cursor, &self.lang);
970        let value_text = match &self.selection {
971            SelectionBinding::Single(sig) => match sig.get() {
972                // The cursor on the one selected day is that day, said once
973                // and marked selected: "vendredi 12 mars 2027 (sélectionné)".
974                // Written out as the cursor followed by the selection, it was
975                // the same date twice, and that is where a single calendar
976                // opens: a date field's popover puts its cursor on the date
977                // the field holds. A range keeps both, since a day inside a
978                // range is not the range.
979                Some(day) if day == cursor => resolve_message_widget(
980                    "calendar-value-on-selection",
981                    &[("date", FluentValue::from(focused))],
982                ),
983                Some(day) => with_selection(focused, full_date(day, &self.lang)),
984                None => focused,
985            },
986            SelectionBinding::Range { value, .. } => match value.get() {
987                Some(r) => with_selection(
988                    focused,
989                    resolve_message_widget(
990                        "calendar-date-range",
991                        &[
992                            ("start", FluentValue::from(full_date(r.start, &self.lang))),
993                            ("end", FluentValue::from(full_date(r.end, &self.lang))),
994                        ],
995                    ),
996                ),
997                None => focused,
998            },
999        };
1000        builder.set_value(value_text);
1001
1002        // Framework a11y walker sets `set_disabled` from arena state.
1003        builder.add_action(Action::Focus);
1004    }
1005}
1006
1007/// The grid's value when something other than the day under the cursor is
1008/// selected: the cursor, then the selection, "samedi 13 mars 2027
1009/// (sélection : vendredi 12 mars 2027)".
1010fn with_selection(focused: String, selection: String) -> String {
1011    resolve_message_widget(
1012        "calendar-value-with-selection",
1013        &[
1014            ("focused", FluentValue::from(focused)),
1015            ("selection", FluentValue::from(selection)),
1016        ],
1017    )
1018}
1019
1020// ── Internal builders ─────────────────────────────────────────────────
1021
1022fn build_weekday_row(
1023    ctx: &mut BuildContext,
1024    first_dow: Weekday,
1025    week_number_col_width: f32,
1026) -> WidgetId {
1027    // `week_number_col_width` already carries the text scale (computed by the
1028    // caller). Apply the same scale to the local constants.
1029    let scale = ctx.text_scale();
1030    let mut row = HStack::new().spacing(cal_recipe::CALENDAR_CELL_GAP * scale);
1031    if week_number_col_width > 0.0 {
1032        // Empty corner cell above the week-number column.
1033        let spacer = ctx.add(
1034            FixedSize::new()
1035                .width(week_number_col_width)
1036                .height(cal_recipe::CALENDAR_WEEKDAY_ROW_HEIGHT * scale)
1037                .child(Spacer::new()),
1038        );
1039        row = row.child(spacer);
1040    }
1041    let first_offset = first_dow.to_monday_zero_offset();
1042    for i in 0..7 {
1043        let dow = weekday_from_monday_zero(first_offset + i);
1044        let key = weekday_short_key(dow);
1045        let label = resolve_message_widget(key, &[]);
1046        let long_label =
1047            resolve_message_widget(crate::common::datetime::weekday_long_key(dow), &[]);
1048        let text = TextWidget::new(lit!(label))
1049            .style(TextStyleRole::Body)
1050            .color(TextRole::Secondary)
1051            .single_line()
1052            .a11y_hidden();
1053        let text_id = ctx.add(text);
1054        let cell = WeekdayHeaderCell::new(
1055            text_id,
1056            long_label,
1057            cal_recipe::CALENDAR_CELL_SIZE * scale,
1058            cal_recipe::CALENDAR_WEEKDAY_ROW_HEIGHT * scale,
1059        );
1060        row = row.child(ctx.add(cell));
1061    }
1062    // AT: the row containing the column headers is itself a Row.
1063    // WAI-ARIA grid pattern wants Row > ColumnHeader, not Group >
1064    // ColumnHeader.
1065    ctx.add(row.access_role(Role::Row))
1066}
1067
1068struct BuildGridParams {
1069    visible_month: Signal<YearMonth>,
1070    focused_date: Signal<Date>,
1071    focused: Signal<bool>,
1072    selection: SelectionBinding,
1073    first_dow: Weekday,
1074    week_numbers: WeekNumberDisplay,
1075    min_date: Option<Date>,
1076    max_date: Option<Date>,
1077    disabled_filter: Option<DisabledDateFilter>,
1078    enabled: bool,
1079    on_selection_changed: Option<OnSelectionChanged>,
1080    on_range_changed: Option<OnRangeChanged>,
1081    on_activate: Option<OnActivate>,
1082    /// The locale the day cells name their dates in.
1083    lang: LanguageIdentifier,
1084    /// Where the grid records the cells it builds, for the calendar root.
1085    day_cells: DayCells,
1086}
1087
1088#[allow(clippy::too_many_arguments)]
1089fn build_footer(
1090    ctx: &mut BuildContext,
1091    show_today: bool,
1092    visible_month: Signal<YearMonth>,
1093    focused_date: Signal<Date>,
1094    calendar_focused: Signal<bool>,
1095    selection: SelectionBinding,
1096    on_selection_changed: Option<OnSelectionChanged>,
1097    on_month_changed: Option<OnMonthChanged>,
1098    lang: &LanguageIdentifier,
1099) -> WidgetId {
1100    let mut row = HStack::new().spacing(8.0);
1101    if show_today {
1102        let today_label = resolve_message_widget("calendar-button-today", &[]);
1103        let cb_visible = visible_month.clone();
1104        let cb_focused = focused_date.clone();
1105        let cb_selection = selection.clone();
1106        let cb_on_sel = on_selection_changed.clone();
1107        let cb_on_month = on_month_changed.clone();
1108        let cb_lang = lang.clone();
1109        let today_btn = Button::new(lit!(today_label))
1110            .variant(ButtonVariant::Filled)
1111            .on_activate_fn(move |ctx_evt| {
1112                let today = today_local();
1113                let new_month = YearMonth::from_date(today);
1114                if cb_visible.get() != new_month {
1115                    cb_visible.set(new_month);
1116                    if let Some(cb) = cb_on_month.as_ref() {
1117                        cb(new_month, ctx_evt);
1118                    }
1119                }
1120                cb_focused.set(today);
1121                if let SelectionBinding::Single(sig) = &cb_selection
1122                    && sig.get() != Some(today)
1123                {
1124                    sig.set(Some(today));
1125                    if let Some(cb) = cb_on_sel.as_ref() {
1126                        cb(Some(today), ctx_evt);
1127                    }
1128                }
1129                // Focus stays on this button, so the day it moved to is
1130                // announced, in full, which says the month as well. The
1131                // header's arrows do the same for theirs, and for the same
1132                // reason: the grid is not a live region. Not while the grid
1133                // holds focus, where the move of its cursor says it.
1134                if !calendar_focused.get() {
1135                    ctx_evt.announce(full_date(today, &cb_lang));
1136                }
1137                ctx_evt.request_frame();
1138            });
1139        row = row.child(today_btn);
1140    }
1141    if let SelectionBinding::Range { value, .. } = &selection {
1142        let status_label = TextWidget::new(lit!(""))
1143            .style(TextStyleRole::Body)
1144            .color(TextRole::Secondary)
1145            .text(range_status(value, lang))
1146            .single_line()
1147            .a11y_hidden();
1148        let spacer = ctx.add(Spacer::new());
1149        row = row.child(spacer).child(status_label);
1150    } else {
1151        row = row.child(Spacer::new());
1152    }
1153    ctx.add(row)
1154}
1155
1156/// The line under a range calendar saying what is selected: "Sélection :
1157/// 1er mars 2027 – 12 mars 2027".
1158///
1159/// Derived from the committed range itself, so it follows a commit from the
1160/// keyboard or from the application as well as a click. It is on screen only
1161/// (the grid's value already says it, in words), so the shorter medium date
1162/// keeps it on one line and the en-dash costs nobody anything.
1163fn range_status(value: &Signal<Option<DateRange>>, lang: &LanguageIdentifier) -> Signal<String> {
1164    let lang = lang.clone();
1165    value.map(move |range| match range {
1166        Some(r) => resolve_message_widget(
1167            "calendar-range-status",
1168            &[
1169                ("start", FluentValue::from(medium_date(r.start, &lang))),
1170                ("end", FluentValue::from(medium_date(r.end, &lang))),
1171            ],
1172        ),
1173        None => String::new(),
1174    })
1175}
1176
1177// ── Weekday header cell (per-cell a11y wrapper) ───────────────────────
1178
1179#[derive(Debug)]
1180struct WeekdayHeaderCell {
1181    child_id: WidgetId,
1182    long_label: String,
1183    cell_size: f32,
1184    cell_height: f32,
1185}
1186
1187impl WeekdayHeaderCell {
1188    fn new(child_id: WidgetId, long_label: String, cell_size: f32, cell_height: f32) -> Self {
1189        Self {
1190            child_id,
1191            long_label,
1192            cell_size,
1193            cell_height,
1194        }
1195    }
1196}
1197
1198impl Widget for WeekdayHeaderCell {
1199    fn layout_response(
1200        &self,
1201        _proposal: SizeProposal,
1202        _ctx: &LayoutContext,
1203    ) -> teksilo_core::widget::LayoutResponse {
1204        Size::new(self.cell_size, self.cell_height).into()
1205    }
1206
1207    fn place_children(
1208        &self,
1209        bounds: Rect,
1210        _proposal: SizeProposal,
1211        children: &mut [WidgetPlacement],
1212        _ctx: &LayoutContext,
1213    ) {
1214        for child in children.iter_mut() {
1215            child.origin = Point::new(bounds.x, bounds.y);
1216            child.size = bounds.size();
1217        }
1218    }
1219
1220    fn children(&self) -> Vec<WidgetId> {
1221        vec![self.child_id]
1222    }
1223
1224    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1225        builder.set_role(Role::ColumnHeader);
1226        builder.set_name(&self.long_label);
1227    }
1228}
1229
1230// ── CalendarBody — the 6×7 grid widget ────────────────────────────────
1231
1232struct CalendarBody {
1233    params: BuildGridParams,
1234    row_ids: RefCell<Vec<WidgetId>>,
1235}
1236
1237impl std::fmt::Debug for CalendarBody {
1238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1239        f.debug_struct("CalendarBody").finish()
1240    }
1241}
1242
1243impl CalendarBody {
1244    fn new(params: BuildGridParams) -> Self {
1245        Self {
1246            params,
1247            row_ids: RefCell::new(Vec::new()),
1248        }
1249    }
1250}
1251
1252impl Widget for CalendarBody {
1253    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1254        // Compute the 6×7 grid once for the current visible month.
1255        let ym = self.params.visible_month.get();
1256        let first_of_month = ym.first_day();
1257        let first_dow_offset = first_of_month.weekday().to_monday_zero_offset();
1258        let target_first_offset = self.params.first_dow.to_monday_zero_offset();
1259        // Days to step backward from the first of the month to land on
1260        // the row's first day.
1261        let lead = (first_dow_offset - target_first_offset).rem_euclid(7);
1262        let grid_start = first_of_month
1263            .checked_sub(jiff::Span::new().days(lead as i32 as i64))
1264            .unwrap_or(first_of_month);
1265
1266        // Grow the grid with the global accessibility text scale. A scale
1267        // change rebuilds the whole Calendar (the binding lives on the top-level
1268        // widget), so reading it at build and multiplying here is sufficient.
1269        let scale = ctx.text_scale();
1270        let mut row_ids = Vec::with_capacity(6);
1271        let cell_size = cal_recipe::CALENDAR_CELL_SIZE * scale;
1272        let cell_height = cal_recipe::CALENDAR_CELL_SIZE * scale;
1273        let gap = cal_recipe::CALENDAR_CELL_GAP * scale;
1274        let week_number_col_width = match self.params.week_numbers {
1275            WeekNumberDisplay::None => 0.0,
1276            _ => cal_recipe::CALENDAR_WEEK_NUMBER_COLUMN_WIDTH * scale,
1277        };
1278
1279        let mut cells = Vec::with_capacity(42);
1280        for week in 0..6 {
1281            let mut row = HStack::new().spacing(gap);
1282            if week_number_col_width > 0.0 {
1283                // ISO week number = week containing the Thursday.
1284                let week_first = grid_start
1285                    .checked_add(jiff::Span::new().days((week * 7) as i64))
1286                    .unwrap_or(grid_start);
1287                let iso_wk = week_first
1288                    .checked_add(jiff::Span::new().days(3i64))
1289                    .unwrap_or(week_first)
1290                    .iso_week_date();
1291                let label_text = format!("{}", iso_wk.week());
1292                let week_text = TextWidget::new(lit!(label_text))
1293                    .style(TextStyleRole::Body)
1294                    .color(TextRole::Secondary)
1295                    .single_line()
1296                    .a11y_hidden();
1297                let week_text_id = ctx.add(week_text);
1298                row = row.child(
1299                    ctx.add(
1300                        FixedSize::new()
1301                            .width(week_number_col_width)
1302                            .height(cell_height)
1303                            .child(Center::new().child(week_text_id)),
1304                    ),
1305                );
1306            }
1307            for day_idx in 0..7 {
1308                let day_offset = (week * 7 + day_idx) as i64;
1309                let day_date = grid_start
1310                    .checked_add(jiff::Span::new().days(day_offset))
1311                    .unwrap_or(grid_start);
1312                let cell = DayCell::new(
1313                    day_date,
1314                    self.params.visible_month.clone(),
1315                    self.params.focused_date.clone(),
1316                    self.params.focused.clone(),
1317                    self.params.selection.clone(),
1318                    cell_size,
1319                    self.params.min_date,
1320                    self.params.max_date,
1321                    self.params.disabled_filter.clone(),
1322                    self.params.enabled,
1323                    self.params.on_selection_changed.clone(),
1324                    self.params.on_range_changed.clone(),
1325                    self.params.on_activate.clone(),
1326                    self.params.lang.clone(),
1327                );
1328                let cell_id = ctx.add(cell);
1329                cells.push((day_date, cell_id));
1330                row = row.child(cell_id);
1331            }
1332            // AT: each week is a Role::Row; the WAI-ARIA grid pattern
1333            // expects Grid > Row > GridCell.
1334            row_ids.push(ctx.add(row.access_role(Role::Row)));
1335        }
1336        let mut col = VStack::new().spacing(gap);
1337        for id in &row_ids {
1338            col = col.child(*id);
1339        }
1340        let col_id = ctx.add(col);
1341        *self.row_ids.borrow_mut() = vec![col_id];
1342        // Replaced, not appended: a change of month rebuilds this grid with
1343        // new cells, and the ones it had are gone from the arena.
1344        *self.params.day_cells.borrow_mut() = cells;
1345        // Bind `visible_month` at `Rebuild` level so navigating prev/
1346        // next month triggers a full re-`build()` of this widget,
1347        // regenerating the 42 DayCells with new dates. Relayout would
1348        // only re-measure existing cells, leaving them frozen on the
1349        // month they were constructed with.
1350        let self_id = ctx.self_id();
1351        self.params.visible_month.bind_to(
1352            self_id,
1353            ctx.binding_registry(),
1354            teksilo_core::binding::BindingLevel::Rebuild,
1355        );
1356        vec![col_id]
1357    }
1358
1359    fn layout_response(
1360        &self,
1361        proposal: SizeProposal,
1362        ctx: &LayoutContext,
1363    ) -> teksilo_core::widget::LayoutResponse {
1364        let row_ids = self.row_ids.borrow();
1365        match row_ids.first() {
1366            Some(id) => ctx
1367                .child_size(*id, proposal)
1368                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
1369            None => proposal.resolve(0.0, 0.0),
1370        }
1371        .into()
1372    }
1373
1374    fn place_children(
1375        &self,
1376        bounds: Rect,
1377        _proposal: SizeProposal,
1378        children: &mut [WidgetPlacement],
1379        _ctx: &LayoutContext,
1380    ) {
1381        for child in children.iter_mut() {
1382            child.origin = bounds.origin();
1383            child.size = bounds.size();
1384        }
1385    }
1386
1387    fn children(&self) -> Vec<WidgetId> {
1388        self.row_ids.borrow().clone()
1389    }
1390
1391    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1392        // Structural: the calendar root carries the grid's role and name.
1393        // Transparent rather than hidden, because the two are not the same
1394        // thing to an adapter. `common_filter` drops a `GenericContainer`
1395        // and keeps its children, and drops a hidden node together with
1396        // everything under it (`accesskit_consumer` `filters.rs:17-34`).
1397        // This used to be a hidden `Group`, which on its own took the six
1398        // weeks and their 42 days out of every platform's tree: no screen
1399        // reader could read a day except through the grid's value, and the
1400        // day named as the grid's active descendant would have had no row
1401        // above it.
1402        builder.set_role(Role::GenericContainer);
1403    }
1404}
1405
1406// ── Keyboard handler factory ──────────────────────────────────────────
1407
1408fn build_keyboard_handler(
1409    visible_month: Signal<YearMonth>,
1410    focused_date: Signal<Date>,
1411    mode: Signal<CalendarMode>,
1412    selection: SelectionBinding,
1413    min_date: Option<Date>,
1414    max_date: Option<Date>,
1415    disabled_filter: Option<DisabledDateFilter>,
1416    on_selection_changed: Option<OnSelectionChanged>,
1417    on_range_changed: Option<OnRangeChanged>,
1418    on_activate: Option<OnActivate>,
1419    on_month_changed: Option<OnMonthChanged>,
1420    enabled: bool,
1421    first_dow: Weekday,
1422) -> impl Fn(&WidgetEvent, &mut EventContext) -> EventResponse + 'static {
1423    let first_offset = first_dow.to_monday_zero_offset();
1424    move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
1425        if !enabled {
1426            return EventResponse::Ignored;
1427        }
1428        let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
1429            return EventResponse::Ignored;
1430        };
1431        // The months and years views have keys of their own. The day keys
1432        // below used to run there too, on a day grid nobody could see: an
1433        // arrow moved its cursor in silence and Enter wrote that day into the
1434        // selection. `T` alone means the same in every view.
1435        let view = mode.get();
1436        if view != CalendarMode::Days && !matches!(key, Key::Character('t' | 'T')) {
1437            let zoom = zoom_grid::Zoom {
1438                visible_month: visible_month.clone(),
1439                focused_date: focused_date.clone(),
1440                mode: mode.clone(),
1441            };
1442            return zoom.handle_key(*key, view, on_month_changed.as_ref(), ctx);
1443        }
1444        let cur = focused_date.get();
1445        let mut new_focus: Option<Date> = None;
1446        let mut new_visible: Option<YearMonth> = None;
1447        let mut commit: bool = false;
1448
1449        match key {
1450            Key::ArrowLeft => new_focus = step_focus(cur, -1),
1451            Key::ArrowRight => new_focus = step_focus(cur, 1),
1452            Key::ArrowUp => new_focus = step_focus(cur, -7),
1453            Key::ArrowDown => new_focus = step_focus(cur, 7),
1454            // Accelerator + Home / End (⌘ on macOS) jumps to the first / last
1455            // day of the month; plain Home / End stay within the week.
1456            Key::Home if modifiers.command() => {
1457                let ym = YearMonth::from_date(cur);
1458                new_focus = Some(ym.first_day());
1459            }
1460            Key::End if modifiers.command() => {
1461                let ym = YearMonth::from_date(cur);
1462                new_focus = Some(ym.last_day());
1463            }
1464            Key::Home => {
1465                let dow_offset = cur.weekday().to_monday_zero_offset();
1466                let lead = (dow_offset - first_offset).rem_euclid(7);
1467                new_focus = step_focus(cur, -(lead as i32));
1468            }
1469            Key::End => {
1470                let dow_offset = cur.weekday().to_monday_zero_offset();
1471                let lead = (dow_offset - first_offset).rem_euclid(7);
1472                new_focus = step_focus(cur, 6 - lead as i32);
1473            }
1474            Key::PageUp if modifiers.shift() => {
1475                let ym = YearMonth::from_date(cur).offset_months(-12);
1476                new_visible = Some(ym);
1477                new_focus = clamp_to_month(cur, ym);
1478            }
1479            Key::PageDown if modifiers.shift() => {
1480                let ym = YearMonth::from_date(cur).offset_months(12);
1481                new_visible = Some(ym);
1482                new_focus = clamp_to_month(cur, ym);
1483            }
1484            Key::PageUp => {
1485                let ym = YearMonth::from_date(cur).offset_months(-1);
1486                new_visible = Some(ym);
1487                new_focus = clamp_to_month(cur, ym);
1488            }
1489            Key::PageDown => {
1490                let ym = YearMonth::from_date(cur).offset_months(1);
1491                new_visible = Some(ym);
1492                new_focus = clamp_to_month(cur, ym);
1493            }
1494            Key::Enter | Key::Space => {
1495                commit = true;
1496            }
1497            Key::Escape => {
1498                if let SelectionBinding::Range { anchor, .. } = &selection
1499                    && anchor.get().is_some()
1500                {
1501                    anchor.set(None);
1502                    return EventResponse::Handled;
1503                }
1504                return EventResponse::Ignored;
1505            }
1506            Key::Character(c) if (*c == 't' || *c == 'T') => {
1507                let today = today_local();
1508                let ym = YearMonth::from_date(today);
1509                if visible_month.get() != ym {
1510                    visible_month.set(ym);
1511                    if let Some(cb) = on_month_changed.as_ref() {
1512                        cb(ym, ctx);
1513                    }
1514                }
1515                focused_date.set(today);
1516                ctx.request_frame();
1517                return EventResponse::Handled;
1518            }
1519            _ => return EventResponse::Ignored,
1520        }
1521
1522        if let Some(nf) = new_focus {
1523            // Clamp to min/max.
1524            let nf = match (min_date, max_date) {
1525                (Some(min), _) if nf < min => min,
1526                (_, Some(max)) if nf > max => max,
1527                _ => nf,
1528            };
1529            focused_date.set(nf);
1530            // If the new focus crosses out of the visible month, follow.
1531            let nfm = YearMonth::from_date(nf);
1532            if YearMonth::from_date(cur) != nfm && new_visible.is_none() {
1533                new_visible = Some(nfm);
1534            }
1535        }
1536        if let Some(nv) = new_visible
1537            && visible_month.get() != nv
1538        {
1539            visible_month.set(nv);
1540            if let Some(cb) = on_month_changed.as_ref() {
1541                cb(nv, ctx);
1542            }
1543        }
1544        if commit {
1545            let target = focused_date.get();
1546            if !is_date_disabled(target, min_date, max_date, disabled_filter.as_ref()) {
1547                commit_date(
1548                    target,
1549                    &selection,
1550                    on_selection_changed.as_ref(),
1551                    on_range_changed.as_ref(),
1552                    on_activate.as_ref(),
1553                    ctx,
1554                );
1555            }
1556        }
1557        ctx.request_frame();
1558        EventResponse::Handled
1559    }
1560}
1561
1562fn step_focus(cur: Date, days: i32) -> Option<Date> {
1563    cur.checked_add(jiff::Span::new().days(days as i64)).ok()
1564}
1565
1566fn clamp_to_month(cur: Date, ym: YearMonth) -> Option<Date> {
1567    let last = ym.last_day().day();
1568    let day = cur.day().min(last);
1569    Date::new(ym.year(), ym.month(), day).ok()
1570}
1571
1572pub(crate) fn is_date_disabled(
1573    d: Date,
1574    min: Option<Date>,
1575    max: Option<Date>,
1576    filter: Option<&DisabledDateFilter>,
1577) -> bool {
1578    if let Some(min) = min
1579        && d < min
1580    {
1581        return true;
1582    }
1583    if let Some(max) = max
1584        && d > max
1585    {
1586        return true;
1587    }
1588    if let Some(f) = filter
1589        && f(d)
1590    {
1591        return true;
1592    }
1593    false
1594}
1595
1596pub(crate) fn commit_date(
1597    d: Date,
1598    selection: &SelectionBinding,
1599    on_sel: Option<&OnSelectionChanged>,
1600    on_range: Option<&OnRangeChanged>,
1601    on_activate: Option<&OnActivate>,
1602    ctx: &mut EventContext,
1603) {
1604    match selection {
1605        SelectionBinding::Single(sig) => {
1606            sig.set(Some(d));
1607            if let Some(cb) = on_sel {
1608                cb(Some(d), ctx);
1609            }
1610            if let Some(cb) = on_activate {
1611                cb(d, ctx);
1612            }
1613        }
1614        SelectionBinding::Range { value, anchor } => {
1615            match anchor.get() {
1616                None => {
1617                    // First click: park the anchor; don't touch the
1618                    // committed `value` yet. Observers of `value`
1619                    // shouldn't see a transient one-day range.
1620                    // `on_selection_changed` fires to signal intent
1621                    // ("user clicked here, range pending"); the actual
1622                    // committed range arrives on the second click.
1623                    anchor.set(Some(d));
1624                    if let Some(cb) = on_sel {
1625                        cb(Some(d), ctx);
1626                    }
1627                }
1628                Some(start) => {
1629                    // Second click: build the range (DateRange::new
1630                    // swaps if end < start), drop the anchor, commit.
1631                    let range = DateRange::new(start, d);
1632                    anchor.set(None);
1633                    value.set(Some(range));
1634                    if let Some(cb) = on_range {
1635                        cb(Some(range), ctx);
1636                    }
1637                    if let Some(cb) = on_sel {
1638                        cb(Some(d), ctx);
1639                    }
1640                }
1641            }
1642        }
1643    }
1644}