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}