Skip to main content

herogpui_components/
calendar.rs

1//! Calendar & RangeCalendar — port of `@heroui/calendar` and
2//! `@heroui/date-picker`'s range grid (std-only date math).
3
4use gpui::{
5    prelude::*, px, App, Entity, IntoElement, RenderOnce, SharedString, StatefulInteractiveElement,
6    Styled, Window,
7};
8use std::sync::OnceLock;
9
10use herogpui_core::element_id;
11use herogpui_theme::ActiveTheme;
12
13use crate::a11y::{self, A11y as _};
14use crate::calendar_keys;
15use crate::calendar_view::{self, PageBehavior, SelectionAlignment, VisibleDuration};
16use crate::date_constraints::{DateConstraints, Weekday};
17
18use crate::icons;
19
20/// A plain proleptic-Gregorian date.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct Date {
23    /// Calendar year.
24    pub year: i32,
25    /// 1–12
26    pub month: u32,
27    /// 1–31
28    pub day: u32,
29}
30
31impl Date {
32    /// Creates a date from `year`, `month` and `day` without validating them.
33    pub fn new(year: i32, month: u32, day: u32) -> Self {
34        Self { year, month, day }
35    }
36
37    /// Today's date in the OS local time zone.
38    pub fn today() -> Self {
39        // v3 marks "today" through React Aria's `today(getLocalTimeZone())`:
40        // the OS local zone's civil date, not UTC's. West of UTC the UTC
41        // date is ahead of the local one; east of UTC it is behind.
42        let now = jiff::Zoned::now();
43        civil_date_at(now.timestamp().as_second(), now.offset().seconds())
44    }
45
46    /// Formats the date as `YYYY-MM-DD`.
47    pub fn format_iso(&self) -> String {
48        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
49    }
50}
51
52/// The civil date for `unix_secs` seconds past the UNIX epoch, observed in a
53/// zone `utc_offset_secs` east of UTC. Private seam so tests can prove the
54/// day crossings of [`Date::today`] without changing the machine timezone.
55fn civil_date_at(unix_secs: i64, utc_offset_secs: i32) -> Date {
56    civil_from_days((unix_secs + i64::from(utc_offset_secs)).div_euclid(86_400))
57}
58
59/// Days since epoch -> civil date (Howard Hinnant's algorithm).
60pub fn civil_from_days(z: i64) -> Date {
61    let z = z + 719_468;
62    let era = z.div_euclid(146_097);
63    let doe = z.rem_euclid(146_097);
64    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
65    let y = yoe + era * 400;
66    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
67    let mp = (5 * doy + 2) / 153;
68    let d = doy - (153 * mp + 2) / 5 + 1;
69    let m = if mp < 10 { mp + 3 } else { mp - 9 };
70    Date::new((if m <= 2 { y + 1 } else { y }) as i32, m as u32, d as u32)
71}
72
73/// `date` shifted by `delta` days, crossing month and year boundaries.
74pub fn add_days(date: &Date, delta: i64) -> Date {
75    civil_from_days(days_from_civil(date) + delta)
76}
77
78/// Weekday of `date` with Monday as 0, matching [`first_weekday_pub`].
79pub fn weekday_index(date: Date) -> usize {
80    // Epoch day 0 (1970-01-01) was a Thursday (=3 with Monday as 0).
81    (days_from_civil(&date) + 3).rem_euclid(7) as usize
82}
83
84/// The running locale's abbreviated month name for 1-12.
85///
86/// CLDR is asked for the abbreviation rather than the first three bytes of the
87/// full name: `"Januar"[..3]` happens to read well in German and would panic
88/// outright on a Japanese month name whose first character is three bytes wide.
89pub fn month_abbr(month: u32) -> &'static str {
90    &system_month_abbrs()[(month.clamp(1, 12) - 1) as usize]
91}
92
93/// Days-from-civil for `date` (epoch day number).
94pub fn days_from_civil(d: &Date) -> i64 {
95    let y = if d.month <= 2 { d.year - 1 } else { d.year } as i64;
96    let era = y.div_euclid(400);
97    let yoe = y - era * 400;
98    let m = d.month as i64;
99    let mp = if m > 2 { m - 3 } else { m + 9 };
100    let doy = (153 * mp + 2) / 5 + d.day as i64 - 1;
101    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
102    era * 146_097 + doe - 719_468
103}
104
105/// Number of days in `month` of `year`, accounting for leap years; an out-of-range month yields 28.
106pub fn days_in_month(year: i32, month: u32) -> u32 {
107    match month {
108        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
109        4 | 6 | 9 | 11 => 30,
110        2 if is_leap(year) => 29,
111        2 => 28,
112        _ => 28,
113    }
114}
115
116fn is_leap(year: i32) -> bool {
117    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
118}
119
120/// 0 = Monday … 6 = Sunday for the first day of the month.
121fn first_weekday(year: i32, month: u32) -> usize {
122    let days = days_from_civil(&Date::new(year, month, 1));
123    // Epoch day 0 (1970-01-01) was a Thursday (=3 with Monday as 0).
124    ((days + 3).rem_euclid(7)) as usize
125}
126
127/// `.calendar` is `w-63 max-w-63` — 63 spacing units, so 252px, which is
128/// exactly seven 36px cells.
129pub const CALENDAR_WIDTH: gpui::Pixels = px(252.);
130
131/// The English month names, used when CLDR has nothing to say for the running
132/// locale. A calendar with no heading is worse than one headed in English.
133const FALLBACK_MONTH_NAMES: [&str; 12] = [
134    "January",
135    "February",
136    "March",
137    "April",
138    "May",
139    "June",
140    "July",
141    "August",
142    "September",
143    "October",
144    "November",
145    "December",
146];
147
148/// The month names CLDR gives for one locale, in calendar order.
149///
150/// v3 renders the heading through `Intl.DateTimeFormat`, so the names follow
151/// the reader's locale rather than the source language. The ICU data is
152/// compiled into the binary, so this asks CLDR the same question `Intl` does.
153pub(crate) fn month_names_for_locale(locale: &str) -> Option<[String; 12]> {
154    month_labels_for_locale(locale, icu_datetime::fieldsets::M::long())
155}
156
157/// The twelve month labels one `M` field set prints for one locale.
158fn month_labels_for_locale(
159    locale: &str,
160    fieldset: icu_datetime::fieldsets::M,
161) -> Option<[String; 12]> {
162    use icu_datetime::{input::Date as IcuDate, DateTimeFormatter};
163    use icu_locale_core::Locale as IcuLocale;
164
165    let locale = locale.parse::<IcuLocale>().ok()?;
166    let formatter = DateTimeFormatter::try_new(locale.into(), fieldset).ok()?;
167    let mut labels: Vec<String> = Vec::with_capacity(12);
168    for month in 1..=12u8 {
169        // Any year and day work: the `M` field set prints the month alone.
170        let date = IcuDate::try_new_iso(2000, month, 1).ok()?;
171        labels.push(formatter.format(&date).to_string());
172    }
173    labels.try_into().ok()
174}
175
176/// The abbreviated month names CLDR gives for one locale, in calendar order.
177pub(crate) fn month_abbrs_for_locale(locale: &str) -> Option<[String; 12]> {
178    month_labels_for_locale(locale, icu_datetime::fieldsets::M::medium())
179}
180
181/// The running locale's month names, resolved once.
182fn system_month_names() -> &'static [String; 12] {
183    static SYSTEM_MONTH_NAMES: OnceLock<[String; 12]> = OnceLock::new();
184    SYSTEM_MONTH_NAMES.get_or_init(|| {
185        crate::date_constraints::system_locale_tags()
186            .iter()
187            .find_map(|tag| month_names_for_locale(tag))
188            .unwrap_or_else(|| FALLBACK_MONTH_NAMES.map(str::to_owned))
189    })
190}
191
192/// The heading CLDR gives one locale for a year and month.
193///
194/// The order is the locale's, not a template's: `"January 2026"` in English and
195/// a year-first heading in Japanese, which no `"{month} {year}"` format string
196/// can produce.
197pub(crate) fn month_heading_for_locale(locale: &str, year: i32, month: u32) -> Option<String> {
198    date_heading_for_locale(locale, Date::new(year, month.clamp(1, 12), 1))
199}
200
201/// Format the month containing this actual Gregorian day.
202pub(crate) fn date_heading_for_locale(locale: &str, date: Date) -> Option<String> {
203    use icu_datetime::{fieldsets, input::Date as IcuDate, options::YearStyle, DateTimeFormatter};
204    use icu_locale_core::Locale as IcuLocale;
205
206    let locale = locale.parse::<IcuLocale>().ok()?;
207    let formatter = DateTimeFormatter::try_new(
208        locale.into(),
209        fieldsets::YM::long().with_year_style(YearStyle::Full),
210    )
211    .ok()?;
212    let month = u8::try_from(date.month).ok()?;
213    let day = u8::try_from(date.day).ok()?;
214    Some(
215        formatter
216            .format(&IcuDate::try_new_iso(date.year, month, day).ok()?)
217            .to_string(),
218    )
219}
220
221/// The running locale's year-and-month heading locale, resolved once.
222fn system_month_heading_locale() -> Option<&'static String> {
223    static SYSTEM_HEADING_LOCALE: OnceLock<Option<String>> = OnceLock::new();
224    SYSTEM_HEADING_LOCALE
225        .get_or_init(|| {
226            crate::date_constraints::system_locale_tags()
227                .into_iter()
228                .find(|tag| month_heading_for_locale(tag, 2000, 1).is_some())
229        })
230        .as_ref()
231}
232
233/// The heading for a year and month, in the running locale.
234pub fn month_year_heading(year: i32, month: u32) -> String {
235    system_month_heading_locale()
236        .and_then(|locale| month_heading_for_locale(locale, year, month))
237        .unwrap_or_else(|| format!("{} {year}", month_name(month)))
238}
239
240/// A day cell's accessible name: the date, then React Aria's
241/// `dateSelected` template (`"{date} selected"` in en-US) when it is
242/// selected, in the chrome locale.
243pub(crate) fn day_cell_name(date: String, is_selected: bool, cx: &App) -> SharedString {
244    if is_selected {
245        crate::i18n::ui_string_with(crate::i18n::UiString::DateSelected, &date, cx)
246    } else {
247        date.into()
248    }
249}
250
251/// The running locale's abbreviated month names, resolved once.
252fn system_month_abbrs() -> &'static [String; 12] {
253    static SYSTEM_MONTH_ABBRS: OnceLock<[String; 12]> = OnceLock::new();
254    SYSTEM_MONTH_ABBRS.get_or_init(|| {
255        crate::date_constraints::system_locale_tags()
256            .iter()
257            .find_map(|tag| month_abbrs_for_locale(tag))
258            .unwrap_or_else(|| {
259                FALLBACK_MONTH_NAMES.map(|name| name.chars().take(3).collect::<String>())
260            })
261    })
262}
263
264fn next_month(y: i32, m: u32) -> (i32, u32) {
265    if m == 12 {
266        (y + 1, 1)
267    } else {
268        (y, m + 1)
269    }
270}
271
272fn prev_month(y: i32, m: u32) -> (i32, u32) {
273    if m == 1 {
274        (y - 1, 12)
275    } else {
276        (y, m - 1)
277    }
278}
279
280/// Steps a (year, month) pair by ±1. `dir` is a *direction*, not a count.
281pub fn bump_month(y: i32, m: u32, dir: i32) -> (i32, u32) {
282    if dir >= 0 {
283        next_month(y, m)
284    } else {
285        prev_month(y, m)
286    }
287}
288
289/// The (year, month) `delta` months away, for any size of `delta`.
290///
291/// `bump_month` reads its argument as a direction and moves one month whatever
292/// the magnitude, which made shift+Page Up move a single month instead of a
293/// year. Counting in months from year zero keeps the wrap arithmetic in one
294/// place.
295pub fn add_months(year: i32, month: u32, delta: i32) -> (i32, u32) {
296    let total = i64::from(year) * 12 + i64::from(month) - 1 + i64::from(delta);
297    #[allow(clippy::cast_possible_truncation)]
298    let y = total.div_euclid(12) as i32;
299    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
300    let m = total.rem_euclid(12) as u32 + 1;
301    (y, m)
302}
303
304/// English month name for 1–12.
305pub fn month_name(month: u32) -> &'static str {
306    &system_month_names()[(month.clamp(1, 12) - 1) as usize]
307}
308
309/// Public wrapper over `first_weekday`.
310pub fn first_weekday_pub(year: i32, month: u32) -> usize {
311    first_weekday(year, month)
312}
313
314// ---------------------------------------------------------------------------
315// Calendar
316// ---------------------------------------------------------------------------
317
318/// State entity for [`Calendar`].
319pub struct CalendarState {
320    /// Year of the month currently shown.
321    pub view_year: i32,
322    /// Month (1-12) currently shown.
323    pub view_month: u32,
324    /// Anchor day for the week and day views; the month view ignores it.
325    pub view_day: u32,
326    /// The most recently selected date, if any.
327    pub selected: Option<Date>,
328    /// Every selected date. Multiple mode toggles this set; `selected` keeps
329    /// the most recent value for scalar callers.
330    pub selected_dates: Vec<Date>,
331    /// Set once the user pages or picks a date, after which
332    /// `selectionAlignment` stops re-deriving the visible range.
333    pub user_navigated: bool,
334}
335
336impl CalendarState {
337    /// Creates a state showing the current month with no selection.
338    pub fn new(_cx: &mut App) -> Self {
339        let t = Date::today();
340        Self {
341            view_year: t.year,
342            view_month: t.month,
343            view_day: t.day,
344            selected: None,
345            selected_dates: Vec::new(),
346            user_navigated: false,
347        }
348    }
349
350    /// Creates a state showing the month of `selected`, with that date selected.
351    pub fn with_selected(_cx: &mut App, selected: Date) -> Self {
352        Self {
353            view_year: selected.year,
354            view_month: selected.month,
355            view_day: selected.day,
356            selected: Some(selected),
357            selected_dates: vec![selected],
358            user_navigated: false,
359        }
360    }
361
362    /// The most recently selected date, if any.
363    pub fn selected(&self) -> Option<Date> {
364        self.selected
365    }
366
367    /// Every selected date, in the order they were picked.
368    pub fn selected_dates(&self) -> &[Date] {
369        &self.selected_dates
370    }
371
372    /// Toggles `date` under `mode`, keeping `selected` pointing at the most
373    /// recent pick so single-selection callers keep working.
374    pub fn toggle(&mut self, date: Date, mode: herogpui_core::SelectionMode) {
375        match mode {
376            herogpui_core::SelectionMode::None => {}
377            herogpui_core::SelectionMode::Single => {
378                self.selected = Some(date);
379                self.selected_dates = vec![date];
380            }
381            herogpui_core::SelectionMode::Multiple => {
382                match self.selected_dates.iter().position(|d| *d == date) {
383                    Some(i) => {
384                        self.selected_dates.remove(i);
385                        self.selected = self.selected_dates.last().copied();
386                    }
387                    None => {
388                        self.selected_dates.push(date);
389                        self.selected = Some(date);
390                    }
391                }
392            }
393        }
394        self.user_navigated = true;
395    }
396
397    /// The date the visible range starts from.
398    pub fn anchor(&self) -> Date {
399        Date::new(self.view_year, self.view_month, self.view_day.max(1))
400    }
401
402    /// Moves the visible range, recording that the user drove it.
403    pub fn set_anchor(&mut self, date: Date) {
404        self.view_year = date.year;
405        self.view_month = date.month;
406        self.view_day = date.day;
407        self.user_navigated = true;
408    }
409}
410
411type OnChange = std::sync::Arc<dyn Fn(&Option<Date>, &mut Window, &mut App) + 'static>;
412type OnChangeAll = std::sync::Arc<dyn Fn(&[Date], &mut Window, &mut App) + 'static>;
413
414/// What `Calendar.Cell`'s render function is handed.
415///
416/// v3's render props for the cell: `formattedDate` is the localized day label,
417/// and the state fields say what the cell is.
418#[derive(Clone, Debug)]
419#[non_exhaustive]
420pub struct CalendarCellState {
421    /// The date this cell draws.
422    pub date: Date,
423    /// `formattedDate` — the day label, as this port writes it.
424    pub formatted_date: SharedString,
425    /// `isSelected`
426    pub is_selected: bool,
427    /// `isUnavailable`
428    pub is_unavailable: bool,
429    /// `isOutsideMonth`
430    pub is_outside_month: bool,
431    /// Today, which v3 marks with `data-today`.
432    pub is_today: bool,
433    /// Outside the min/max range, or the calendar is disabled.
434    pub is_disabled: bool,
435}
436
437/// HeroUI Calendar, with controlled selection through the entity.
438#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
439#[derive(IntoElement)]
440pub struct Calendar {
441    /// `value` — v3's controlled selection, stored for the first render only.
442    /// The outer `Option` distinguishes an unset builder from `value(null)`,
443    /// the explicitly controlled empty selection.
444    value: Option<Option<Date>>,
445    /// `value` for `selectionMode="multiple"` — stored for the first render
446    /// only. `Some` distinguishes an unset builder from the explicitly
447    /// controlled empty selection.
448    values: Option<Vec<Date>>,
449    /// `defaultValue` — seeds the state on the first render only.
450    default_value: Option<Date>,
451    /// `defaultValue` for `selectionMode="multiple"`.
452    default_values: Option<Vec<Date>>,
453    id: gpui::ElementId,
454    state: Entity<CalendarState>,
455    constraints: DateConstraints,
456    is_disabled: bool,
457    /// Retained picker exits keep their calendar painted while making every
458    /// descendant inert. This is distinct from `is_disabled`: the exit must
459    /// preserve normal colors and opacity while releasing focus and input.
460    inert: bool,
461    is_read_only: bool,
462    /// Set by a picker: take the focus as the panel opens. See
463    /// [`Calendar::autofocus_grid`].
464    autofocus_grid: bool,
465    /// The calendar system the grid is drawn in, when the caller names one.
466    /// `None` follows the operating system's locale, which is v3's default.
467    calendar_system: Option<crate::calendar_system::CalendarSystem>,
468    /// `Calendar.CellIndicator` — whether a day carries a mark. v3 uses it for
469    /// event dots; the closure is handed the date.
470    cell_indicator: Option<Box<dyn Fn(Date) -> bool + 'static>>,
471    /// `Calendar.Cell`'s render props: the closure replaces the day label and is
472    /// handed the state v3 passes it.
473    cell: Option<Box<dyn Fn(&CalendarCellState) -> gpui::AnyElement + 'static>>,
474    /// `Calendar.NavButton` children — the paging glyphs, previous then next.
475    nav_icons: Option<(&'static str, &'static str)>,
476    is_invalid: bool,
477    focused_value: Option<Date>,
478    selection_mode: herogpui_core::SelectionMode,
479    duration: VisibleDuration,
480    page_behavior: PageBehavior,
481    selection_alignment: SelectionAlignment,
482    /// `isYearPickerOpen` — `None` leaves the component holding the state,
483    /// seeded from `defaultYearPickerOpen`.
484    year_picker_open: Option<bool>,
485    default_year_picker_open: bool,
486    /// `Calendar.YearPickerGrid.visibleYears` — `None` uses the v3 default:
487    /// the full min/max span when both exist, otherwise 20.
488    visible_years: Option<usize>,
489    /// `Calendar.YearPickerTriggerHeading.offset.months`.
490    year_heading_offset_months: i32,
491    on_year_picker_open_change:
492        Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
493    on_focus_change: Option<std::sync::Arc<dyn Fn(&Date, &mut Window, &mut App) + 'static>>,
494    on_change: Option<OnChange>,
495    on_change_all: Option<OnChangeAll>,
496    /// The fill a hovered nav arrow takes, in place of `--default`.
497    nav_hover_bg: Option<gpui::Hsla>,
498    /// The fill a hovered year cell takes, in place of `--default`.
499    year_hover_bg: Option<gpui::Hsla>,
500    /// The fill a hovered plain (unselected, non-today) day takes, in
501    /// place of `--default`. Today and selected days keep their own fills.
502    day_hover_bg: Option<gpui::Hsla>,
503    /// The `sx` slot, refined over the root style at the end of render.
504    sx: Option<Box<gpui::StyleRefinement>>,
505}
506
507impl Calendar {
508    /// The locale whose calendar system this grid is drawn in.
509    ///
510    /// v3 has no prop for this: it wraps the calendar in an `I18nProvider`
511    /// whose locale carries the Unicode `-u-ca-` extension, and its
512    /// "International Calendars" example is exactly that. gpui has no subtree
513    /// context to put a provider in, so the locale is named on the component
514    /// the provider would have wrapped. Omit it and the operating system's
515    /// locale decides, which is what v3 does by default.
516    pub fn locale(mut self, tag: impl AsRef<str>) -> Self {
517        self.calendar_system = crate::calendar_system::CalendarSystem::for_locale(tag.as_ref());
518        self
519    }
520
521    /// The system this grid measures in: the caller's, else the platform's.
522    fn system(&self) -> &crate::calendar_system::CalendarSystem {
523        self.calendar_system
524            .as_ref()
525            .unwrap_or_else(|| crate::calendar_system::system())
526    }
527
528    /// `focusedValue` — the date carrying the focus ring, independent of the
529    /// selection.
530    pub fn focused_value(mut self, date: Date) -> Self {
531        self.focused_value = Some(date);
532        self
533    }
534
535    /// `onFocusChange` — fires when a different date takes focus.
536    pub fn on_focus_change(
537        mut self,
538        handler: impl Fn(&Date, &mut Window, &mut App) + 'static,
539    ) -> Self {
540        self.on_focus_change = Some(std::sync::Arc::new(handler));
541        self
542    }
543
544    /// `value` — v3's controlled-selection spelling, as a pure builder.
545    ///
546    /// The bound [`CalendarState`] owns the selection once the grid renders,
547    /// so this seeds the state on the first render only, winning over
548    /// [`Calendar::default_value`] the way v3's controlled prop outranks the
549    /// uncontrolled seed; calling `.value(..)` twice keeps the last call, like
550    /// every other builder here. `None` is v3's `null` — an explicitly
551    /// controlled empty selection, which still outranks `default_value`. A
552    /// later value is an imperative update rather than a builder: write the
553    /// caller-owned state entity.
554    pub fn value(mut self, date: Option<Date>) -> Self {
555        self.value = Some(date);
556        self
557    }
558
559    /// `value` for `selectionMode="multiple"`, as a pure builder.
560    ///
561    /// The bound [`CalendarState`] owns the selection once the grid renders,
562    /// so this seeds the state on the first render only, winning over
563    /// [`Calendar::default_values`] the way v3's controlled prop outranks the
564    /// uncontrolled seed; calling `.values(..)` twice keeps the last call,
565    /// like every other builder here. An empty iterator is the explicitly
566    /// controlled empty selection, which still outranks `default_values`. A
567    /// later value is an imperative update rather than a builder: write the
568    /// caller-owned state entity.
569    pub fn values(mut self, dates: impl IntoIterator<Item = Date>) -> Self {
570        self.values = Some(dates.into_iter().collect());
571        self
572    }
573
574    /// Creates a calendar bound to `state`.
575    pub fn new(state: Entity<CalendarState>) -> Self {
576        Self {
577            value: None,
578            values: None,
579            default_value: None,
580            default_values: None,
581            id: gpui::ElementId::named_usize("cal", state.entity_id().as_u64() as usize),
582            state,
583            constraints: DateConstraints::new().with_hero_calendar_bounds(),
584            is_disabled: false,
585            inert: false,
586            is_read_only: false,
587            autofocus_grid: false,
588            calendar_system: None,
589            cell_indicator: None,
590            cell: None,
591            nav_icons: None,
592            is_invalid: false,
593            focused_value: None,
594            selection_mode: herogpui_core::SelectionMode::Single,
595            duration: VisibleDuration::default(),
596            page_behavior: PageBehavior::default(),
597            selection_alignment: SelectionAlignment::default(),
598            year_picker_open: None,
599            default_year_picker_open: false,
600            visible_years: None,
601            year_heading_offset_months: 0,
602            on_year_picker_open_change: None,
603            on_focus_change: None,
604            on_change: None,
605            on_change_all: None,
606            day_hover_bg: None,
607            nav_hover_bg: None,
608            year_hover_bg: None,
609            sx: None,
610        }
611    }
612
613    /// `defaultValue` — the uncontrolled initial selection.
614    ///
615    /// Written into the state on the first render only, so it seeds the
616    /// component without fighting the user afterwards.
617    pub fn default_value(mut self, value: Date) -> Self {
618        self.default_value = Some(value);
619        self
620    }
621
622    /// `defaultValue` for `selectionMode="multiple"`.
623    pub fn default_values(mut self, values: impl IntoIterator<Item = Date>) -> Self {
624        self.default_values = Some(values.into_iter().collect());
625        self
626    }
627
628    /// `minValue` — the earliest selectable date.
629    pub fn min_value(mut self, date: Date) -> Self {
630        self.constraints.min_value = Some(date);
631        self
632    }
633
634    /// `maxValue` — the latest selectable date.
635    pub fn max_value(mut self, date: Date) -> Self {
636        self.constraints.max_value = Some(date);
637        self
638    }
639
640    /// `isDateUnavailable` — blocks individual dates inside the range.
641    pub fn is_date_unavailable(mut self, f: impl Fn(Date) -> bool + 'static) -> Self {
642        self.constraints.is_date_unavailable = Some(std::sync::Arc::new(f));
643        self
644    }
645
646    /// `firstDayOfWeek` — which weekday starts the grid.
647    pub fn first_day_of_week(mut self, day: Weekday) -> Self {
648        self.constraints.first_day_of_week = day;
649        self
650    }
651
652    /// `weeksInMonth` — forces the grid to this many rows.
653    pub fn weeks_in_month(mut self, rows: usize) -> Self {
654        self.constraints.weeks_in_month = Some(rows);
655        self
656    }
657
658    /// Sets whether the calendar is disabled (`isDisabled`).
659    pub fn is_disabled(mut self, v: bool) -> Self {
660        self.is_disabled = v;
661        self
662    }
663
664    /// Makes the calendar a visual-only surface for a retained overlay exit.
665    ///
666    /// Unlike [`Calendar::is_disabled`], this does not dim the calendar or
667    /// report disabled cells. It only removes focus, pointer and keyboard
668    /// activation while the exit animation paints.
669    pub(crate) fn inert(mut self, v: bool) -> Self {
670        self.inert = v;
671        self
672    }
673
674    /// `Calendar.CellIndicator` — mark the days this returns `true` for.
675    /// `Calendar.Cell`'s render function — draw the day yourself.
676    ///
677    /// v3 hands it `{formattedDate, isSelected, isUnavailable, isOutsideMonth}`;
678    /// this port computes each of those to draw the cell, so the closure is
679    /// handed the same [`CalendarCellState`] rather than the values being
680    /// unavailable.
681    pub fn cell(
682        mut self,
683        render: impl Fn(&CalendarCellState) -> gpui::AnyElement + 'static,
684    ) -> Self {
685        self.cell = Some(Box::new(render));
686        self
687    }
688
689    /// Sets a predicate marking dates that get a cell indicator.
690    pub fn cell_indicator(mut self, f: impl Fn(Date) -> bool + 'static) -> Self {
691        self.cell_indicator = Some(Box::new(f));
692        self
693    }
694
695    /// `Calendar.NavButton` children — the previous and next glyphs.
696    pub fn nav_icons(mut self, previous: &'static str, next: &'static str) -> Self {
697        self.nav_icons = Some((previous, next));
698        self
699    }
700
701    /// Sets whether the calendar is read-only (`isReadOnly`).
702    pub fn is_read_only(mut self, v: bool) -> Self {
703        self.is_read_only = v;
704        self
705    }
706
707    /// Takes the focus the first time the grid renders.
708    ///
709    /// Not a v3 prop: React Aria moves the focus into the calendar when a date
710    /// picker's popover opens, and a picker whose arrows do nothing until the
711    /// user finds it with Tab is not the same component. Crate-only, because a
712    /// standalone calendar must *not* steal the focus -- a page with three of
713    /// them would fight over it.
714    pub(crate) fn autofocus_grid(mut self, v: bool) -> Self {
715        self.autofocus_grid = v;
716        self
717    }
718
719    /// Sets whether the calendar is invalid (`isInvalid`).
720    pub fn is_invalid(mut self, v: bool) -> Self {
721        self.is_invalid = v;
722        self
723    }
724
725    /// All five date constraints at once, for callers that already hold a set.
726    pub fn constraints(mut self, constraints: DateConstraints) -> Self {
727        self.constraints = constraints.with_hero_calendar_bounds();
728        self
729    }
730
731    /// `selectionMode` — one date or many.
732    ///
733    /// `Multiple` marks every date in
734    /// [`CalendarState::selected_dates`] and toggles on click.
735    pub fn selection_mode(mut self, mode: herogpui_core::SelectionMode) -> Self {
736        self.selection_mode = mode;
737        self
738    }
739
740    /// `visibleDuration` — a month view, a run of weeks, or a rolling window
741    /// of days.
742    pub fn visible_duration(mut self, duration: VisibleDuration) -> Self {
743        self.duration = duration;
744        self
745    }
746
747    /// `pageBehavior` — whether navigation steps the whole visible range or a
748    /// single month/week/day.
749    pub fn page_behavior(mut self, behavior: PageBehavior) -> Self {
750        self.page_behavior = behavior;
751        self
752    }
753
754    /// `selectionAlignment` — where the selection sits inside the visible
755    /// range, until the user navigates for themselves.
756    pub fn selection_alignment(mut self, alignment: SelectionAlignment) -> Self {
757        self.selection_alignment = alignment;
758        self
759    }
760
761    /// `isYearPickerOpen` — swaps the day grid for a year grid.
762    pub fn is_year_picker_open(mut self, v: bool) -> Self {
763        self.year_picker_open = Some(v);
764        self
765    }
766
767    /// `defaultYearPickerOpen` — the uncontrolled initial state.
768    ///
769    /// Only consulted when `isYearPickerOpen` is not supplied; the component
770    /// then owns the state and the heading toggles it directly.
771    pub fn default_year_picker_open(mut self, v: bool) -> Self {
772        self.default_year_picker_open = v;
773        self
774    }
775
776    /// `Calendar.YearPickerGrid.visibleYears` — the size of its sliding window.
777    pub fn visible_years(mut self, count: usize) -> Self {
778        self.visible_years = Some(count.max(1));
779        self
780    }
781
782    /// `Calendar.YearPickerTriggerHeading.offset` — shifts its displayed month.
783    pub fn offset(mut self, months: i32) -> Self {
784        self.year_heading_offset_months = months;
785        self
786    }
787
788    /// `onYearPickerOpenChange` — reports the built-in heading trigger opening
789    /// or closing the year grid.
790    pub fn on_year_picker_open_change(
791        mut self,
792        f: impl Fn(&bool, &mut Window, &mut App) + 'static,
793    ) -> Self {
794        self.on_year_picker_open_change = Some(std::sync::Arc::new(f));
795        self
796    }
797
798    /// Sets the handler called with the selected date when it changes (`onChange`).
799    pub fn on_change(mut self, f: impl Fn(&Option<Date>, &mut Window, &mut App) + 'static) -> Self {
800        self.on_change = Some(std::sync::Arc::new(f));
801        self
802    }
803
804    /// `onChange` for `selectionMode="multiple"`.
805    pub fn on_change_all(mut self, f: impl Fn(&[Date], &mut Window, &mut App) + 'static) -> Self {
806        self.on_change_all = Some(std::sync::Arc::new(f));
807        self
808    }
809
810    /// The fill a hovered nav arrow takes, in place of `--default`.
811    pub fn nav_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
812        self.nav_hover_bg = Some(color.into());
813        self
814    }
815
816    /// The fill a hovered year cell takes, in place of `--default`.
817    pub fn year_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
818        self.year_hover_bg = Some(color.into());
819        self
820    }
821
822    /// The fill a hovered plain (unselected, non-today) day takes, in
823    /// place of `--default`. Today and selected days keep their own fills.
824    pub fn day_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
825        self.day_hover_bg = Some(color.into());
826        self
827    }
828
829    /// The one slot for caller-owned low-level styling: GPUI's styling methods
830    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
831    /// applied to the calendar's root element after every value the component
832    /// and the active theme chose, so they win.
833    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
834        crate::util::refine_sx(&mut self.sx, style);
835        self
836    }
837}
838
839/// The per-frame facts every cell needs, bundled so the helpers below take a
840/// readable argument list.
841struct Frame<'a> {
842    selected: Option<Date>,
843    /// Every selected date, for the multiple mode.
844    selected_dates: &'a [Date],
845    today: Date,
846    cursor: &'a Entity<Option<Date>>,
847    base: &'a str,
848    /// The date wearing the focus ring: `focusedValue` when the caller controls
849    /// it, otherwise wherever the arrow keys have walked to. `None` while the
850    /// grid does not hold the keyboard.
851    focused: Option<Date>,
852    anchor: Date,
853    visible_start: Option<Date>,
854    cell_size: gpui::Pixels,
855}
856
857impl Calendar {
858    /// One day cell, shared by the month, week and day views.
859    fn day_cell(
860        &self,
861        date: Date,
862        outside_month: bool,
863        frame: &Frame<'_>,
864        key: String,
865        cx: &App,
866    ) -> gpui::AnyElement {
867        let colors = cx.colors();
868        let accent = colors.accent;
869        let unavailable = self.constraints.is_unavailable(date);
870        let disabled = outside_month
871            || self.is_disabled
872            || self.constraints.out_of_range(date)
873            || frame
874                .visible_start
875                .is_some_and(|start| days_from_civil(&date) < days_from_civil(&start));
876        let focusable = !disabled;
877        let eligible = focusable && !unavailable;
878        let selectable = eligible && !self.is_read_only && !self.inert;
879        // In the multiple mode membership of the set is what marks a date.
880        let is_sel = eligible
881            && if self.selection_mode == herogpui_core::SelectionMode::Multiple {
882                frame.selected_dates.contains(&date)
883            } else {
884                frame.selected == Some(date)
885            };
886        let is_today = date == frame.today;
887
888        // Uniform circular hit area centred in the slot. The debug selector
889        // lets the headless tests read the cell's laid-out bounds.
890        let indicator_key = format!("{key}-indicator");
891        // The day's own content joins the skin before the press wrap:
892        // children added after `pressed` land on the slot and fight the
893        // skin for width.
894        let mut circle = gpui::div()
895            .id(gpui::ElementId::Name(key.clone().into()))
896            .debug_selector(move || key)
897            .size(frame.cell_size)
898            .rounded_full()
899            .flex()
900            .items_center()
901            .justify_center()
902            .text_size(px(14.))
903            .line_height(px(20.))
904            .font_weight(gpui::FontWeight::MEDIUM)
905            .child(match &self.cell {
906                Some(render) => render(&CalendarCellState {
907                    date,
908                    formatted_date: self.day_label(date).into(),
909                    is_selected: is_sel,
910                    is_unavailable: unavailable,
911                    is_outside_month: outside_month,
912                    is_today,
913                    is_disabled: disabled,
914                }),
915                None => self.day_label(date).into_any_element(),
916            });
917
918        let marker = if self.is_invalid {
919            colors.danger.color
920        } else {
921            accent.color
922        };
923
924        // `.calendar__cell[data-pressed]` fills with `bg-default` and scales
925        // to 0.95 -- every cell, today included.
926        let press_box = crate::anim::PressBox {
927            height: frame.cell_size,
928            padding_x: None,
929            width: Some(frame.cell_size),
930            min_width: None,
931            text_size: px(14.),
932            line_height: px(20.),
933            gap: px(0.),
934            radius: frame.cell_size / 2.,
935            shrink_x: true,
936            scale: crate::anim::PRESSED_SCALE_DEEP,
937        };
938        if outside_month {
939            circle = circle.text_color(colors.muted);
940        } else if is_sel {
941            circle = circle.bg(marker).text_color(if self.is_invalid {
942                colors.danger.foreground
943            } else {
944                accent.foreground
945            });
946            if selectable {
947                // `.calendar__cell[data-pressed][data-selected]` fills
948                // `bg-accent-hover`, the one pressed recolour the pinned CSS
949                // nests under the scale.
950                let pressed_bg = if self.is_invalid {
951                    colors.danger.hover()
952                } else {
953                    accent.hover()
954                };
955                circle = crate::anim::pressed_with_background(circle, press_box, pressed_bg, cx);
956            }
957        } else if disabled || unavailable {
958            // v3 dims both states and reserves the line-through for disabled
959            // in-month dates; unavailable dates remain focusable.
960            circle = circle
961                .text_color(colors.muted)
962                .cursor(gpui::CursorStyle::OperationNotAllowed)
963                // A calendar that is disabled as a whole already dims its
964                // root; avoid stacking the same opacity on every cell.
965                .when(!self.is_disabled, |cell| cell.opacity(cx.layout().disabled_opacity));
966            if disabled {
967                circle = circle.line_through();
968            }
969        } else if is_today {
970            // `.calendar__cell[data-today]` fills `bg-accent-soft` with
971            // `text-accent-soft-foreground`; its own hover (not selected)
972            // deepens the same soft fill rather than the generic `bg-default`.
973            circle = circle
974                .bg(accent.soft())
975                .text_color(accent.soft_foreground(colors.foreground));
976            if selectable {
977                let hover_bg = accent.soft_hover();
978                let pressed_bg = colors.default.color;
979                circle = circle
980                    .cursor(crate::util::interactive_cursor(cx))
981                    .hover(move |s| s.bg(hover_bg));
982                // A chained `.active` would overwrite the pressed refinement
983                // and drop the 0.95 scale; the background must merge with the
984                // press geometry in one refinement.
985                circle = crate::anim::pressed_with_background(circle, press_box, pressed_bg, cx);
986            }
987        } else {
988            circle = circle.text_color(colors.foreground);
989            if selectable {
990                // `.calendar__cell:hover` (not selected) fills with `bg-default`,
991                // the full token -- same as the pressed fill.
992                let hover_bg = self.day_hover_bg.unwrap_or(colors.default.color);
993                let pressed_bg = hover_bg;
994                circle = circle
995                    .cursor(crate::util::interactive_cursor(cx))
996                    .hover(move |s| s.bg(hover_bg));
997                circle = crate::anim::pressed_with_background(circle, press_box, pressed_bg, cx);
998            }
999        }
1000
1001        // `.calendar__cell` takes `status-focused`, independently of selection,
1002        // so it shows on an unselected date too. A ring rather than a border:
1003        // a border shrinks the 36px circle as the cursor lands on it.
1004        // The ring rides as an overlay child rather than a spread shadow: a
1005        // shadow keeps the element's own corner radius, which on a 36px circle
1006        // reads as a visibly squarer arc than the `r + gap + 2` CSS draws, and
1007        // the shadow shader needs a blur to paint at all. `circle` here is the
1008        // press *slot* once the cell is selectable -- the stable footprint that
1009        // keeps the id, the focus and the resting corners while the skin inside
1010        // carries the 0.95 scale -- so that is the node the ring belongs on, and
1011        // the overlay's absolute box adds nothing to the slot's width.
1012        let circle = crate::util::with_focus_ring_overlay(
1013            circle,
1014            crate::util::shows_focus_ring(
1015                !self.inert && !outside_month && frame.focused == Some(date),
1016                cx,
1017            ),
1018            true,
1019            frame.cell_size / 2.,
1020            Vec::new(),
1021            cx,
1022        );
1023        // `useCalendarCell` puts `role: 'gridcell'` on the `<td>` and
1024        // `role: 'button'` on the inner date; this port draws one circle,
1025        // which is the pressable half, so that is the node. The gridcell
1026        // wrapper has no id and would produce no node even if it claimed one.
1027        // Named after the layout chain: `.shots/design_audit.py` reads sizes
1028        // out of builder chains with character-windowed regexes.
1029        let mut circle = circle
1030            .a11y_named(
1031                a11y::Role::Button,
1032                &a11y::Name::labelled(day_cell_name(
1033                    format!("{} {}", date.day, month_year_heading(date.year, date.month)),
1034                    is_sel,
1035                    cx,
1036                )),
1037            )
1038            .a11y_selected(is_sel);
1039
1040        if selectable {
1041            let cursor = frame.cursor.clone();
1042            let st = self.state.clone();
1043            let selection_mode = self.selection_mode;
1044            let on_change = self.on_change.clone();
1045            let on_change_all = self.on_change_all.clone();
1046            let on_focus = self.on_focus_change.clone();
1047            let anchor = frame.anchor;
1048            circle = circle.on_click(move |_, window, cx| {
1049                cursor.update(cx, |focused, cx| {
1050                    *focused = Some(date);
1051                    cx.notify();
1052                });
1053                if let Some(cb) = &on_focus {
1054                    cb(&date, window, cx);
1055                }
1056                let mode = selection_mode;
1057                let selected_dates = st.update(cx, |s, cx| {
1058                    // `toggle` also records that the user took over
1059                    // navigation, so the alignment pass stops moving the range.
1060                    s.toggle(date, mode);
1061                    s.set_anchor(anchor);
1062                    cx.notify();
1063                    s.selected_dates.clone()
1064                });
1065                if let Some(cb) = &on_change {
1066                    cb(&Some(date), window, cx);
1067                }
1068                if let Some(cb) = &on_change_all {
1069                    cb(&selected_dates, window, cx);
1070                }
1071            });
1072        }
1073
1074        // `Calendar.CellIndicator` — a dot under the day, which is what v3's
1075        // event calendar draws.
1076        let marked = self.cell_indicator.as_ref().is_some_and(|f| f(date));
1077        gpui::div()
1078            .flex_1()
1079            .h(frame.cell_size)
1080            .relative()
1081            .flex()
1082            .items_center()
1083            .justify_center()
1084            .child(circle)
1085            .when(marked, |cell| {
1086                cell.child(
1087                    gpui::div()
1088                        .absolute()
1089                        // The debug selector lets the headless tests read the
1090                        // dot's laid-out bounds.
1091                        .debug_selector(move || indicator_key)
1092                        // `.calendar__cell-indicator` hangs at `bottom-1` --
1093                        // 4px above the cell's lower edge, not the 2px a
1094                        // first read of the 3px dot suggests -- centred by
1095                        // the cell's flex alignment.
1096                        .bottom(px(4.))
1097                        // `.calendar__cell-indicator` is `size-[3px]` with a
1098                        // `rounded-[2px]` corner -- smaller than any radius
1099                        // token, so the literal is v3's own.
1100                        .size(px(3.))
1101                        .rounded(px(2.))
1102                        .bg(if is_sel { accent.foreground } else { marker }),
1103                )
1104            })
1105            .when(outside_month, |cell| cell.opacity(0.5))
1106            .into_any_element()
1107    }
1108
1109    /// The seven column headers.
1110    /// `.calendar__grid-header` — the seven `.calendar__header-cell` columns.
1111    fn weekday_header(&self, cx: &App) -> gpui::Div {
1112        let muted = cx.colors().muted;
1113        gpui::div().flex().children(
1114            self.constraints
1115                .first_day_of_week
1116                .header_row()
1117                .into_iter()
1118                .enumerate()
1119                .map(|(column, d)| {
1120                    gpui::div()
1121                    .flex_1()
1122                    .debug_selector({
1123                        let key = format!("{:?}-weekday-{column}", self.id);
1124                        move || key
1125                    })
1126                    .pb(px(8.))
1127                    .text_center()
1128                    // `.calendar__header-cell` is `text-xs`.
1129                    .text_size(px(12.))
1130                    .line_height(px(16.))
1131                    .font_weight(gpui::FontWeight::MEDIUM)
1132                    .text_color(muted)
1133                    .child(d.to_owned())
1134                }),
1135        )
1136    }
1137
1138    /// The heading over one visible month, named in the view calendar.
1139    ///
1140    /// `(year, month)` are the view calendar's. ICU formats an ISO date in
1141    /// whatever calendar the locale names, so the month is converted back to a
1142    /// Gregorian day and handed over with that locale: an Indian grid heads
1143    /// itself in Pausha 1947, not January 2026.
1144    fn month_heading_text(&self, year: i32, month: u32) -> String {
1145        let system = self.system();
1146        let (year, month) = system.add_months(year, month, self.year_heading_offset_months);
1147        let Some(date) = system.to_gregorian(year, month, 1) else {
1148            return String::new();
1149        };
1150        date_heading_for_locale(system.locale(), date)
1151            .unwrap_or_else(|| month_year_heading(date.year, date.month))
1152    }
1153
1154    /// The number a cell prints: the day of the month in the *view* calendar.
1155    ///
1156    /// v3 hands `Calendar.Cell` a `formattedDate` in the calendar the grid is
1157    /// drawn in, so a date's Gregorian day is the wrong label whenever the two
1158    /// systems differ.
1159    fn day_label(&self, date: Date) -> String {
1160        self.system().from_gregorian(date).2.to_string()
1161    }
1162
1163    /// One grid cell whose date the calendar system may not have.
1164    ///
1165    /// Every cell is addressed in the view calendar and converted back to a
1166    /// Gregorian [`Date`] here, so the rest of the component -- constraints,
1167    /// selection, callbacks -- only ever sees Gregorian. A triple the system
1168    /// does not have draws a blank rather than a wrong day.
1169    fn spill_cell(
1170        &self,
1171        date: Option<Date>,
1172        outside_month: bool,
1173        frame: &Frame<'_>,
1174        key: String,
1175        cx: &App,
1176    ) -> gpui::AnyElement {
1177        match date {
1178            Some(date) => self.day_cell(date, outside_month, frame, key, cx),
1179            None => gpui::div().size(frame.cell_size).into_any_element(),
1180        }
1181    }
1182
1183    /// The 7-column grid for a single month, including both adjacent months.
1184    fn month_grid(&self, y: i32, m: u32, frame: &Frame<'_>, cx: &App) -> gpui::AnyElement {
1185        let system = self.system();
1186        let lead = self.constraints.lead_cells_in(system, y, m);
1187        let dim = system.days_in_month(y, m) as usize;
1188        let rows = self.constraints.rows_in(system, y, m);
1189
1190        // `.calendar__grid` holds the header and `.calendar__grid-body`, whose
1191        // children are `.calendar__grid-row`s of cells.
1192        // `useCalendarGrid` is `role: 'grid'`. The header row is
1193        // `aria-hidden` upstream (`headerProps`), so the weekday labels stay
1194        // off the tree; each cell already names the date.
1195        let mut grid = gpui::div()
1196            .id(element_id::scoped(&self.id, format!("grid-{y}-{m}")))
1197            .flex()
1198            .flex_col();
1199        for r in 0..rows {
1200            let mut line = gpui::div().flex();
1201            for c in 0..7 {
1202                let idx = r * 7 + c;
1203                let slot: gpui::AnyElement = if idx < lead {
1204                    let (py, pm) = system.add_months(y, m, -1);
1205                    let day = system.days_in_month(py, pm) as usize - lead + idx + 1;
1206                    self.spill_cell(
1207                        system.to_gregorian(py, pm, day as u32),
1208                        true,
1209                        frame,
1210                        format!("{}-{y}-{m}-outside-{py}-{pm}-d{day}", frame.base),
1211                        cx,
1212                    )
1213                } else {
1214                    let day_num = idx - lead + 1;
1215                    if day_num > dim {
1216                        // The next month's leading days: v3 draws them as cells
1217                        // with `isOutsideMonth`, so the render prop sees them
1218                        // too -- muted and inert either way. Each spill cell
1219                        // carries its *own* next-month date: v3 hands the
1220                        // render prop a real `CalendarDate`, and the closure is
1221                        // the only identity a caller has.
1222                        let nd = day_num - dim;
1223                        let (ny, nm) = system.add_months(y, m, 1);
1224                        self.spill_cell(
1225                            system.to_gregorian(ny, nm, nd as u32),
1226                            true,
1227                            frame,
1228                            format!("{}-{y}-{m}-outside-{ny}-{nm}-d{nd}", frame.base),
1229                            cx,
1230                        )
1231                    } else {
1232                        self.spill_cell(
1233                            system.to_gregorian(y, m, day_num as u32),
1234                            false,
1235                            frame,
1236                            format!("{}-{y}-{m}-d{day_num}", frame.base),
1237                            cx,
1238                        )
1239                    }
1240                };
1241                line = line.child(slot);
1242            }
1243            grid = grid.child(line);
1244        }
1245        grid.a11y(a11y::Role::Grid).into_any_element()
1246    }
1247
1248    /// The year grid shown while the year picker is open.
1249    fn year_grid(
1250        &self,
1251        view: calendar_view::YearGridView<'_>,
1252        year_focus: &gpui::FocusHandle,
1253        heading_focus: &gpui::FocusHandle,
1254        year_picker_own: Option<Entity<bool>>,
1255        window: &Window,
1256        cx: &App,
1257    ) -> gpui::AnyElement {
1258        let colors = cx.colors();
1259        let accent = colors.accent;
1260        let active_year = view.active_year;
1261        // The debug selectors keep the component id's Debug form, which is what
1262        // the headless tests read; the element ids above stay structured.
1263        let base = format!("{:?}", view.base);
1264        // `.calendar-year-picker__year-grid` is `gap-1 p-1`.
1265        let mut grid = gpui::div()
1266            .id(element_id::scoped(view.base, "year-viewport"))
1267            .debug_selector({
1268                let key = format!("{base}-year-viewport");
1269                move || key
1270            })
1271            .size_full()
1272            .overflow_y_scroll()
1273            .track_scroll(&view.scroll.handle)
1274            .flex()
1275            .flex_col()
1276            .gap(px(4.));
1277        for chunk in view.years.chunks(3) {
1278            let mut row = gpui::div().flex().flex_shrink_0().gap(px(4.));
1279            for &year in chunk {
1280                let is_active = year == active_year;
1281                let mut cell = gpui::div()
1282                    .id(element_id::scoped(view.base, format!("y{year}")))
1283                    .debug_selector({
1284                        let key = format!("{base}-y{year}");
1285                        move || key
1286                    })
1287                    .when(!self.is_disabled && !self.inert && is_active, |cell| {
1288                        cell.track_focus(year_focus)
1289                    })
1290                    .flex_1()
1291                    // `.calendar-year-picker__year-cell` is `h-8 px-2.5
1292                    // rounded-3xl text-sm`.
1293                    .h(px(32.))
1294                    .px(px(10.))
1295                    .flex()
1296                    .items_center()
1297                    .justify_center()
1298                    .text_size(px(14.))
1299                    .line_height(px(20.))
1300                    .font_weight(gpui::FontWeight::MEDIUM)
1301                    .rounded(crate::util::control_radius(cx));
1302
1303                if is_active {
1304                    cell = cell.bg(accent.color).text_color(accent.foreground);
1305                } else if !self.is_disabled && !self.inert {
1306                    // `.calendar-year-picker__year-cell:hover` fills
1307                    // `bg-default text-default-foreground`.
1308                    let hover_bg = self.year_hover_bg.unwrap_or(colors.default.color);
1309                    let hover_fg = colors.default.foreground;
1310                    cell = cell
1311                        .text_color(colors.foreground)
1312                        .cursor(crate::util::interactive_cursor(cx))
1313                        .hover(move |s| s.bg(hover_bg).text_color(hover_fg));
1314                }
1315
1316                if !self.is_disabled && !self.inert {
1317                    let st = self.state.clone();
1318                    let on_open = self.on_year_picker_open_change.clone();
1319                    let on_focus = self.on_focus_change.clone();
1320                    let own = year_picker_own.clone();
1321                    let back_to_trigger = heading_focus.clone();
1322                    let system = self.system().clone();
1323                    cell = cell.on_click(move |_, window, cx| {
1324                        let next = st.update(cx, |s, cx| {
1325                            let anchor = s.anchor();
1326                            let next =
1327                                system.add_years(anchor, year - system.from_gregorian(anchor).0);
1328                            s.set_anchor(next);
1329                            cx.notify();
1330                            next
1331                        });
1332                        if year != active_year {
1333                            if let Some(cb) = &on_focus {
1334                                cb(&next, window, cx);
1335                            }
1336                        }
1337                        if let Some(held) = &own {
1338                            held.update(cx, |open, cx| {
1339                                *open = false;
1340                                cx.notify();
1341                            });
1342                        }
1343                        if let Some(cb) = &on_open {
1344                            cb(&false, window, cx);
1345                        }
1346                        window.focus(&back_to_trigger, cx);
1347                    });
1348                }
1349
1350                if is_active {
1351                    cell = crate::util::ring_overlay_if_focused(
1352                        cell,
1353                        year_focus,
1354                        false,
1355                        crate::util::control_radius(cx),
1356                        Vec::new(),
1357                        window,
1358                        cx,
1359                    );
1360                }
1361                cell = cell
1362                    .a11y_named(a11y::Role::Button, &a11y::Name::labelled(year.to_string()))
1363                    .a11y_selected(is_active);
1364                row = row.child(cell.child(year.to_string()));
1365            }
1366            row = row.children((chunk.len()..3).map(|_| gpui::div().flex_1().px(px(10.))));
1367            grid = grid.child(row);
1368        }
1369        grid = grid.a11y(a11y::Role::Grid);
1370        gpui::div()
1371            .absolute()
1372            .inset_0()
1373            .p(px(4.))
1374            .child(grid)
1375            .when_some(view.reveal_row, |viewport, row| {
1376                let scroll = view.scroll.clone();
1377                viewport.on_children_prepainted(move |_, window, cx| {
1378                    scroll.last_target.set(Some((active_year, row)));
1379                    scroll.handle.scroll_to_item(row);
1380                    window.defer(cx, |window, _| window.refresh());
1381                })
1382            })
1383            .into_any_element()
1384    }
1385}
1386
1387impl RenderOnce for Calendar {
1388    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1389        // `value` / `defaultValue` seed the state once, before anything reads
1390        // it. `value` is v3's controlled spelling, so it outranks both
1391        // uncontrolled seeds; the state owns the selection afterwards, and a
1392        // write to the caller-owned entity is the imperative update.
1393        if let Some(date) = self.value {
1394            let state = self.state.clone();
1395            crate::util::seed_once(
1396                window,
1397                cx,
1398                gpui::ElementId::named_usize(
1399                    "calendar-default",
1400                    self.state.entity_id().as_u64() as usize,
1401                ),
1402                move |cx| {
1403                    state.update(cx, |s, cx| {
1404                        s.selected = date;
1405                        s.selected_dates = date.into_iter().collect();
1406                        if let Some(date) = date {
1407                            s.view_year = date.year;
1408                            s.view_month = date.month;
1409                            s.view_day = date.day;
1410                        }
1411                        cx.notify();
1412                    });
1413                },
1414            );
1415        } else if let Some(values) = self.values.clone() {
1416            let state = self.state.clone();
1417            crate::util::seed_once(
1418                window,
1419                cx,
1420                gpui::ElementId::named_usize(
1421                    "calendar-default",
1422                    self.state.entity_id().as_u64() as usize,
1423                ),
1424                move |cx| {
1425                    state.update(cx, |s, cx| {
1426                        s.selected = values.last().copied();
1427                        s.selected_dates = values;
1428                        if let Some(value) = s.selected {
1429                            s.view_year = value.year;
1430                            s.view_month = value.month;
1431                            s.view_day = value.day;
1432                        }
1433                        cx.notify();
1434                    });
1435                },
1436            );
1437        } else if let Some(values) = self.default_values.clone() {
1438            let state = self.state.clone();
1439            crate::util::seed_once(
1440                window,
1441                cx,
1442                gpui::ElementId::named_usize(
1443                    "calendar-default",
1444                    self.state.entity_id().as_u64() as usize,
1445                ),
1446                move |cx| {
1447                    state.update(cx, |s, cx| {
1448                        s.selected = values.last().copied();
1449                        s.selected_dates = values;
1450                        if let Some(value) = s.selected {
1451                            s.view_year = value.year;
1452                            s.view_month = value.month;
1453                            s.view_day = value.day;
1454                        }
1455                        cx.notify();
1456                    });
1457                },
1458            );
1459        } else if let Some(value) = self.default_value {
1460            let state = self.state.clone();
1461            crate::util::seed_once(
1462                window,
1463                cx,
1464                gpui::ElementId::named_usize(
1465                    "calendar-default",
1466                    self.state.entity_id().as_u64() as usize,
1467                ),
1468                move |cx| {
1469                    state.update(cx, |s, cx| {
1470                        s.selected = Some(value);
1471                        s.selected_dates = vec![value];
1472                        s.view_year = value.year;
1473                        s.view_month = value.month;
1474                        s.view_day = value.day;
1475                        cx.notify();
1476                    });
1477                },
1478            );
1479        }
1480
1481        let base = format!("{:?}", self.id);
1482
1483        // `isYearPickerOpen` wins; without it the component holds the flag and
1484        // the heading toggles it, which is what `defaultYearPickerOpen`
1485        // promises. This borrows `cx` mutably, so it precedes the tokens.
1486        let (year_picker_open, year_picker_own) = crate::util::controlled(
1487            window,
1488            cx,
1489            element_id::scoped(&self.id, "yearpicker"),
1490            self.year_picker_open,
1491            self.default_year_picker_open,
1492        );
1493
1494        // The grid is one tab stop with a cursor inside it, the way a list is:
1495        // v3 gives the calendar a roving focus and rings the date it is on.
1496        // `use_keyed_state` takes `cx` mutably, so both precede the theme.
1497        let grid_focus =
1498            crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
1499        let prev_focus =
1500            crate::util::tab_stop_handle(element_id::scoped(&self.id, "prev-focus"), window, cx);
1501        let next_focus =
1502            crate::util::tab_stop_handle(element_id::scoped(&self.id, "next-focus"), window, cx);
1503        let year_focus =
1504            crate::util::tab_stop_handle(element_id::scoped(&self.id, "year-focus"), window, cx);
1505        // Inside a picker the grid takes the focus as the panel opens, so the
1506        // arrows work without hunting for it with Tab.
1507        if self.autofocus_grid && !self.is_disabled && !self.inert && !year_picker_open {
1508            crate::util::focus_once(
1509                window,
1510                cx,
1511                element_id::scoped(&self.id, "autofocus"),
1512                &grid_focus,
1513            );
1514        }
1515        let cursor = window.use_keyed_state(element_id::scoped(&self.id, "cursor"), cx, |_, _| {
1516            None::<Date>
1517        });
1518        let year_cursor =
1519            window.use_keyed_state(element_id::scoped(&self.id, "year-cursor"), cx, |_, _| {
1520                None::<i32>
1521            });
1522        let year_was_open =
1523            window.use_keyed_state(element_id::scoped(&self.id, "year-was-open"), cx, |_, _| {
1524                false
1525            });
1526        let year_trigger_index = window.use_keyed_state(
1527            element_id::scoped(&self.id, "year-trigger-index"),
1528            cx,
1529            |_, _| 0usize,
1530        );
1531        let cursor_at = *cursor.read(cx);
1532
1533        let first_day = self.constraints.first_day_of_week;
1534        let focused_value = self
1535            .focused_value
1536            .map(|date| self.constraints.constrain(date));
1537
1538        let (stored_anchor, selected, selected_dates, navigated) = {
1539            let st = self.state.read(cx);
1540            (
1541                st.anchor(),
1542                st.selected,
1543                st.selected_dates.clone(),
1544                st.user_navigated,
1545            )
1546        };
1547        // `selectionAlignment` frames the range around the selection, but only
1548        // until the user drives navigation themselves.
1549        let anchor = match (navigated, selected) {
1550            (false, Some(sel)) => calendar_view::aligned_anchor_in(
1551                self.system(),
1552                self.duration,
1553                self.selection_alignment,
1554                first_day,
1555                sel,
1556            ),
1557            _ => stored_anchor,
1558        };
1559        let anchor = focused_value.map_or(anchor, |focused| {
1560            let (visible_start, visible_end) =
1561                calendar_view::visible_range(self.system(), self.duration, first_day, anchor);
1562            calendar_view::anchor_following_focus_in(
1563                self.system(),
1564                self.duration,
1565                first_day,
1566                anchor,
1567                visible_start,
1568                visible_end,
1569                focused,
1570            )
1571        });
1572        let initial_year = self
1573            .system()
1574            .from_gregorian(focused_value.unwrap_or(anchor))
1575            .0;
1576        let years = calendar_view::year_window(
1577            self.system(),
1578            initial_year,
1579            self.visible_years,
1580            self.constraints.min_value,
1581            self.constraints.max_value,
1582        );
1583        let first_year = years.first().copied().unwrap_or(initial_year);
1584        let last_year = years.last().copied().unwrap_or(initial_year);
1585        if year_picker_open && !*year_was_open.read(cx) && !self.is_disabled && !self.inert {
1586            year_cursor.update(cx, |year, _| *year = Some(initial_year));
1587            window.focus(&year_focus, cx);
1588        }
1589        let year_just_opened = year_picker_open && !*year_was_open.read(cx);
1590        year_was_open.update(cx, |was_open, _| *was_open = year_picker_open);
1591        let active_year = focused_value
1592            .map(|date| self.system().from_gregorian(date).0)
1593            .or(*year_cursor.read(cx))
1594            .unwrap_or(initial_year)
1595            .max(first_year)
1596            .min(last_year);
1597        let year_scroll_state =
1598            window.use_keyed_state(element_id::scoped(&self.id, "year-scroll"), cx, |_, _| {
1599                std::rc::Rc::new(calendar_view::YearGridScroll::default())
1600            });
1601        let year_scroll = year_scroll_state.read(cx).clone();
1602        let reveal_year_row =
1603            years
1604                .iter()
1605                .position(|year| *year == active_year)
1606                .and_then(|index| {
1607                    let row = index / 3;
1608                    (year_picker_open
1609                        && (year_just_opened
1610                            || year_scroll.last_target.get() != Some((active_year, row))))
1611                    .then_some(row)
1612                });
1613
1614        // `focusedValue` wins; without it the keyboard's own cursor does, and it
1615        // starts from the selection or today.
1616        // Taking the focus puts the ring on the date v3 would have focused --
1617        // the selection, or today -- rather than waiting for a keystroke to
1618        // place it.
1619        let ring_at = focused_value
1620            .or(cursor_at)
1621            .or(selected)
1622            .or_else(|| Some(Date::today()))
1623            .filter(|_| grid_focus.is_focused(window));
1624        let column_width = if matches!(self.duration, VisibleDuration::Months(n) if n > 1) {
1625            px(256.)
1626        } else {
1627            CALENDAR_WIDTH
1628        };
1629        let frame = Frame {
1630            selected,
1631            selected_dates: &selected_dates,
1632            today: Date::today(),
1633            cursor: &cursor,
1634            base: &base,
1635            focused: ring_at,
1636            anchor,
1637            visible_start: matches!(self.duration, VisibleDuration::Days(_)).then_some(anchor),
1638            cell_size: column_width / 7.,
1639        };
1640
1641        let months = calendar_view::month_headings_in(self.system(), self.duration, anchor);
1642        let linear = calendar_view::linear_cells(self.duration, first_day, anchor);
1643        let columns = months.len().max(1);
1644        let mut heading_focuses = Vec::with_capacity(columns);
1645        for index in 0..columns {
1646            heading_focuses.push(crate::util::tab_stop_handle(
1647                element_id::scoped(&element_id::indexed(&self.id, "heading", index), "focus"),
1648                window,
1649                cx,
1650            ));
1651        }
1652        let active_heading_index = (*year_trigger_index.read(cx)).min(columns - 1);
1653        let active_heading_focus = heading_focuses[active_heading_index].clone();
1654
1655        let colors = cx.colors().clone();
1656        let layout = cx.layout().clone();
1657
1658        let nav_target = |dir: i32| {
1659            calendar_view::page_in(
1660                self.system(),
1661                self.duration,
1662                self.page_behavior,
1663                anchor,
1664                dir,
1665            )
1666        };
1667        let (visible_start, visible_end) =
1668            calendar_view::visible_range(self.system(), self.duration, first_day, anchor);
1669        // React Stately checks only the day immediately outside the visible
1670        // range against minValue/maxValue. Unavailable dates do not block
1671        // paging, and readOnly prevents selection without preventing paging.
1672        let previous_disabled =
1673            self.is_disabled || self.constraints.out_of_range(add_days(&visible_start, -1));
1674        let next_disabled =
1675            self.is_disabled || self.constraints.out_of_range(add_days(&visible_end, 1));
1676        let state_for_nav = self.state.clone();
1677        // `Calendar.NavButton` children, defaulting to v3's chevrons.
1678        let (prev_icon, next_icon) = self
1679            .nav_icons
1680            .unwrap_or((icons::CHEVRON_LEFT, icons::CHEVRON_RIGHT));
1681        let nav_btn = |icon_path: &'static str,
1682                       target: Date,
1683                       key: String,
1684                       focus: &gpui::FocusHandle,
1685                       disabled: bool,
1686                       inert: bool,
1687                       window: &mut Window,
1688                       cx: &mut App| {
1689            let state = state_for_nav.clone();
1690            // `.calendar__nav-button:hover` fills with `bg-default`.
1691            let hover_bg = self.nav_hover_bg.unwrap_or(colors.default.color);
1692            // The pinned `[data-pressed]` is a bare `scale(0.95)` with no
1693            // background change, so the hover fill must survive as its own
1694            // refinement and the press stays the backgroundless helper.
1695            let press = crate::anim::PressBox {
1696                height: px(24.),
1697                padding_x: None,
1698                width: Some(px(24.)),
1699                min_width: None,
1700                text_size: px(14.),
1701                line_height: px(20.),
1702                gap: px(0.),
1703                radius: crate::util::soft_radius(cx),
1704                shrink_x: true,
1705                scale: crate::anim::PRESSED_SCALE_DEEP,
1706            };
1707            let selector = key.clone();
1708            let button_id = gpui::ElementId::Name(key.clone().into());
1709            let radius = crate::util::soft_radius(cx);
1710            // `useCalendarBase` names these `previous` / `next` from the
1711            // pinned en-US strings.
1712            let nav_name = crate::i18n::ui_string(
1713                if key.contains("-prev") {
1714                    crate::i18n::UiString::Previous
1715                } else {
1716                    crate::i18n::UiString::Next
1717                },
1718                cx,
1719            );
1720            // The icon joins the skin before the press wrap: children added
1721            // after `pressed` land on the slot and fight the skin for width.
1722            let button = gpui::div()
1723                .id(button_id.clone())
1724                .debug_selector(move || selector)
1725                .when(!disabled && !inert, |b| b.track_focus(focus))
1726                .flex()
1727                .items_center()
1728                .justify_center()
1729                // `.calendar__nav-button` is `size-6 rounded-2xl`.
1730                .size(px(24.))
1731                .rounded(crate::util::soft_radius(cx))
1732                .child(
1733                    gpui::svg()
1734                        // `.calendar__nav-button-icon` is `size-4`.
1735                        .size(px(16.))
1736                        .path(icon_path)
1737                        // `.calendar__nav-button` is `text-accent-soft-foreground`,
1738                        // at rest and hovered alike.
1739                        .text_color(colors.accent.soft_foreground(colors.foreground)),
1740                )
1741                .when(!disabled && !inert, |b| {
1742                    let b = b
1743                        .cursor(crate::util::interactive_cursor(cx))
1744                        .on_click(move |_, _, cx| {
1745                            state.update(cx, |s, cx| {
1746                                s.set_anchor(target);
1747                                cx.notify();
1748                            });
1749                        });
1750                    let b = crate::anim::hover_fade_with_duration(
1751                        b,
1752                        element_id::scoped(&button_id, "hover-fade"),
1753                        (herogpui_core::with_alpha(colors.default.color, 0.0), hover_bg),
1754                        None,
1755                        None,
1756                        move |fill| fill.rounded(radius),
1757                        Some(100),
1758                        window,
1759                        cx,
1760                    );
1761                    crate::anim::pressed(b, press, cx)
1762                })
1763                .when(disabled, |b| b.opacity(layout.disabled_opacity))
1764                .when(year_picker_open, |b| b.invisible());
1765            let button = if inert {
1766                button
1767            } else {
1768                // `button` is the press slot once the nav button is live, and
1769                // the slot is what carries both `rounded(soft_radius)` and the
1770                // focus; the scaled skin inside must not own the ring or it
1771                // would shrink with the press.
1772                crate::util::ring_overlay_if_focused(
1773                    button,
1774                    focus,
1775                    true,
1776                    radius,
1777                    Vec::new(),
1778                    window,
1779                    cx,
1780                )
1781            };
1782            button.a11y_named(a11y::Role::Button, &a11y::Name::labelled(nav_name))
1783        };
1784
1785        // A heading is a plain label only when the picker is controlled without
1786        // a change handler; otherwise it is the built-in year-picker trigger.
1787        let heading = |text: String,
1788                       key: String,
1789                       focus: &gpui::FocusHandle,
1790                       index: usize,
1791                       window: &mut Window,
1792                       cx: &mut App|
1793         -> gpui::AnyElement {
1794            let heading_name = text.clone();
1795            let label = gpui::div()
1796                .text_size(px(14.))
1797                .line_height(px(20.))
1798                .font_weight(gpui::FontWeight::MEDIUM)
1799                .child(text);
1800            let indicator_id =
1801                element_id::scoped(&self.id, format!("year-picker-indicator-{index}"));
1802            match &self.on_year_picker_open_change {
1803                // No handler and no state of our own: a plain label.
1804                None if year_picker_own.is_none() => label.into_any_element(),
1805                None => {
1806                    let own = year_picker_own.clone();
1807                    let opener = year_trigger_index.clone();
1808                    let open = year_picker_open;
1809                    let trigger = gpui::div()
1810                        .id(gpui::ElementId::Name(key.into()))
1811                        .when(!self.is_disabled && !self.inert, |trigger| trigger.track_focus(focus))
1812                        .flex()
1813                        .items_center()
1814                        // `.calendar-year-picker__trigger` is `gap-1 rounded-lg`
1815                        // and hovers nothing: only focus and the open state
1816                        // recolour it.
1817                        .gap(px(4.))
1818                        .px(px(6.))
1819                        .py(px(2.))
1820                        .rounded(crate::util::key_radius(cx))
1821                        .when(!self.is_disabled && !self.inert, |trigger| {
1822                            trigger
1823                                .cursor(crate::util::interactive_cursor(cx))
1824                                .on_click(move |_, _, cx| {
1825                                    opener.update(cx, |value, _| *value = index);
1826                                    if let Some(held) = &own {
1827                                        held.update(cx, |value, cx| {
1828                                            *value = !open;
1829                                            cx.notify();
1830                                        });
1831                                    }
1832                                })
1833                        })
1834                        .when(self.is_disabled, |trigger| {
1835                            trigger.opacity(layout.disabled_opacity)
1836                        });
1837                    let trigger = if self.inert {
1838                        trigger
1839                    } else {
1840                        crate::util::ring_overlay_if_focused(
1841                            trigger,
1842                            focus,
1843                            true,
1844                            crate::util::key_radius(cx),
1845                            Vec::new(),
1846                            window,
1847                            cx,
1848                        )
1849                    };
1850                    trigger
1851                        .a11y_named(a11y::Role::Button, &a11y::Name::labelled(heading_name))
1852                        .a11y_expanded(open)
1853                        .child(label)
1854                        .child(crate::anim::rotating_indicator_with_angle_ease_out(
1855                            &indicator_id,
1856                            open,
1857                            gpui::svg()
1858                                .size(px(12.))
1859                                .path(icons::CHEVRON_DOWN)
1860                                .text_color(colors.muted),
1861                            crate::anim::YEAR_PICKER_INDICATOR_MS,
1862                            crate::anim::YEAR_PICKER_INDICATOR_ANGLE,
1863                            window,
1864                            cx,
1865                        ))
1866                        .into_any_element()
1867                }
1868                Some(cb) => {
1869                    let cb = cb.clone();
1870                    let open = year_picker_open;
1871                    let own = year_picker_own.clone();
1872                    let opener = year_trigger_index.clone();
1873                    let trigger = gpui::div()
1874                        .id(gpui::ElementId::Name(key.into()))
1875                        .when(!self.is_disabled && !self.inert, |trigger| trigger.track_focus(focus))
1876                        .flex()
1877                        .items_center()
1878                        // `.calendar-year-picker__trigger` is `gap-1 rounded-lg`
1879                        // and hovers nothing: only focus and the open state
1880                        // recolour it.
1881                        .gap(px(4.))
1882                        .px(px(6.))
1883                        .py(px(2.))
1884                        .rounded(crate::util::key_radius(cx))
1885                        .when(!self.is_disabled && !self.inert, |trigger| {
1886                            trigger
1887                                .cursor(crate::util::interactive_cursor(cx))
1888                                .on_click(move |_, window, cx| {
1889                                    opener.update(cx, |value, _| *value = index);
1890                                    // Uncontrolled: flip our own copy too, or
1891                                    // the callback would be the only opener.
1892                                    if let Some(held) = &own {
1893                                        held.update(cx, |value, cx| {
1894                                            *value = !open;
1895                                            cx.notify();
1896                                        });
1897                                    }
1898                                    cb(&!open, window, cx);
1899                                })
1900                        })
1901                        .when(self.is_disabled, |trigger| {
1902                            trigger.opacity(layout.disabled_opacity)
1903                        });
1904                    let trigger = if self.inert {
1905                        trigger
1906                    } else {
1907                        crate::util::ring_overlay_if_focused(
1908                            trigger,
1909                            focus,
1910                            true,
1911                            crate::util::key_radius(cx),
1912                            Vec::new(),
1913                            window,
1914                            cx,
1915                        )
1916                    };
1917                    trigger
1918                        .a11y_named(a11y::Role::Button, &a11y::Name::labelled(heading_name))
1919                        .a11y_expanded(open)
1920                        .child(label)
1921                        .child(crate::anim::rotating_indicator_with_angle_ease_out(
1922                            &indicator_id,
1923                            open,
1924                            gpui::svg()
1925                                .size(px(12.))
1926                                .path(icons::CHEVRON_DOWN)
1927                                .text_color(colors.muted),
1928                            crate::anim::YEAR_PICKER_INDICATOR_MS,
1929                            crate::anim::YEAR_PICKER_INDICATOR_ANGLE,
1930                            window,
1931                            cx,
1932                        ))
1933                        .into_any_element()
1934                }
1935            }
1936        };
1937
1938        let mut root = gpui::div()
1939            .id(self.id.clone())
1940            .flex()
1941            .flex_col()
1942            .text_color(colors.surface.foreground)
1943            // readOnly blocks selection, not focus or navigation.
1944            .when(!self.is_disabled && !self.inert && !year_picker_open, |el| {
1945                el.track_focus(&grid_focus)
1946            });
1947
1948        // v3 drives a calendar from the keyboard: arrows step a day and a
1949        // week, Page Up/Down move the visible section, and Enter takes the
1950        // date the ring is on.
1951        if !self.is_disabled && !self.inert && !year_picker_open {
1952            let held = cursor.clone();
1953            let focus = grid_focus.clone();
1954            let prev_control = prev_focus.clone();
1955            let next_control = next_focus.clone();
1956            let heading_controls = heading_focuses.clone();
1957            let controlled_focus = focused_value;
1958            let start = focused_value.or(selected).unwrap_or_else(Date::today);
1959            let constraints = self.constraints.clone();
1960            let state = self.state.clone();
1961            let mode = self.selection_mode;
1962            let read_only = self.is_read_only;
1963            let on_change = self.on_change.clone();
1964            let on_change_all = self.on_change_all.clone();
1965            let on_focus = self.on_focus_change.clone();
1966            let system = self.system().clone();
1967            let duration = self.duration;
1968            let page_behavior = self.page_behavior;
1969            let grid = calendar_keys::DayGrid {
1970                system,
1971                duration,
1972                page_behavior,
1973                first_day,
1974                anchor,
1975                visible_start,
1976                visible_end,
1977            };
1978            root = root.on_key_down(move |event, window, cx| {
1979                let header_focused = prev_control.is_focused(window)
1980                    || next_control.is_focused(window)
1981                    || heading_controls
1982                        .iter()
1983                        .any(|handle| handle.is_focused(window));
1984                if header_focused || !focus.contains_focused(window, cx) {
1985                    return;
1986                }
1987                let at = controlled_focus.or(*held.read(cx)).unwrap_or(start);
1988                let key = event.keystroke.key.as_str();
1989                let shift = event.keystroke.modifiers.shift;
1990                if matches!(key, "enter" | "space") {
1991                    cx.stop_propagation();
1992                    if event.is_held || read_only || !constraints.allows(at) {
1993                        return;
1994                    }
1995                    let selected_dates = state.update(cx, |s, cx| {
1996                        s.toggle(at, mode);
1997                        s.set_anchor(anchor);
1998                        cx.notify();
1999                        s.selected_dates.clone()
2000                    });
2001                    if let Some(cb) = &on_change {
2002                        cb(&Some(at), window, cx);
2003                    }
2004                    if let Some(cb) = &on_change_all {
2005                        cb(&selected_dates, window, cx);
2006                    }
2007                    return;
2008                }
2009                let Some(next) = grid.key_target(key, at, shift) else {
2010                    return;
2011                };
2012                if controlled_focus.is_none() {
2013                    held.update(cx, |v, cx| {
2014                        *v = Some(next);
2015                        cx.notify();
2016                    });
2017                    // React Aria keeps the focused date visible: the grid follows
2018                    // the cursor once it leaves the current visible range. Day
2019                    // views page the whole window directly.
2020                    state.update(cx, |s, cx| {
2021                        let next_anchor = grid.anchor_after_key(key, next, shift);
2022                        if next_anchor != anchor {
2023                            s.set_anchor(next_anchor);
2024                            cx.notify();
2025                        }
2026                    });
2027                }
2028                if let Some(cb) = &on_focus {
2029                    cb(&next, window, cx);
2030                }
2031            });
2032        }
2033
2034        if !self.is_disabled && !self.inert && year_picker_open {
2035            let held = year_cursor;
2036            let focus = year_focus.clone();
2037            let years_for_keys = years.clone();
2038            let own = year_picker_own.clone();
2039            let on_open = self.on_year_picker_open_change.clone();
2040            let on_focus = self.on_focus_change.clone();
2041            let system = self.system().clone();
2042            let controlled_year = focused_value.map(|date| system.from_gregorian(date).0);
2043            let back_to_trigger = active_heading_focus.clone();
2044            root = calendar_keys::on_year_picker_keys(
2045                root,
2046                calendar_keys::YearPickerKeys {
2047                    held,
2048                    focus,
2049                    years: years_for_keys,
2050                    own,
2051                    on_open,
2052                    on_focus,
2053                    system,
2054                    controlled_year,
2055                    back_to_trigger,
2056                    active_year,
2057                    anchor,
2058                },
2059            );
2060        }
2061
2062        let mut body = gpui::div().flex().flex_col().gap(px(4.));
2063        if self.duration.is_month_view() {
2064            root = root.w(column_width * columns as f32 + px(32.) * (columns - 1) as f32);
2065            let mut row = gpui::div().flex().gap(px(32.));
2066            let mut headings = gpui::div().flex().gap(px(32.)).pb(px(16.));
2067            for (i, &(y, m)) in months.iter().enumerate() {
2068                let first = i == 0;
2069                let last = i + 1 == columns;
2070                let mut col = gpui::div().w(column_width).flex().flex_col().gap(px(4.));
2071                // Only the outer columns carry nav buttons; the others keep
2072                // a same-size spacer so every heading lines up.
2073                // The same box as a nav button, so every heading lines up.
2074                let spacer = || gpui::div().size(px(24.)).into_any_element();
2075                headings = headings.child(
2076                    gpui::div().w(column_width).child(
2077                        gpui::div()
2078                        .flex()
2079                        .items_center()
2080                        .justify_between()
2081                        // `.calendar__header` is `px-0.5`.
2082                        .px(px(2.))
2083                        .child(if first {
2084                            nav_btn(
2085                                prev_icon,
2086                                nav_target(-1),
2087                                format!("{base}-prev"),
2088                                &prev_focus,
2089                                previous_disabled,
2090                                self.inert,
2091                                window,
2092                                cx,
2093                            )
2094                                .into_any_element()
2095                        } else {
2096                            spacer()
2097                        })
2098                        .child(heading(
2099                            self.month_heading_text(y, m),
2100                            format!("{base}-heading{i}"),
2101                            &heading_focuses[i],
2102                            i,
2103                            window,
2104                            cx,
2105                        ))
2106                        .child(if last {
2107                            nav_btn(
2108                                next_icon,
2109                                nav_target(1),
2110                                format!("{base}-next"),
2111                                &next_focus,
2112                                next_disabled,
2113                                self.inert,
2114                                window,
2115                                cx,
2116                            )
2117                                .into_any_element()
2118                        } else {
2119                            spacer()
2120                        }),
2121                    ),
2122                );
2123                col = col.child(self.weekday_header(cx));
2124                col = col.child(self.month_grid(y, m, &frame, cx));
2125                row = row.child(col);
2126            }
2127            root = root.child(headings);
2128            body = body.child(row);
2129        } else {
2130            root = root.w(CALENDAR_WIDTH);
2131            root = root.child(
2132                gpui::div()
2133                    .flex()
2134                    .items_center()
2135                    .justify_between()
2136                    // `.calendar__header` is `px-0.5`.
2137                    .px(px(2.))
2138                    .pb(px(16.))
2139                    .child(nav_btn(
2140                        icons::CHEVRON_LEFT,
2141                        nav_target(-1),
2142                        format!("{base}-prev"),
2143                        &prev_focus,
2144                        previous_disabled,
2145                        self.inert,
2146                        window,
2147                        cx,
2148                    ))
2149                    .child(heading(
2150                        calendar_view::range_heading(&linear),
2151                        format!("{base}-heading"),
2152                        &heading_focuses[0],
2153                        0,
2154                        window,
2155                        cx,
2156                    ))
2157                    .child(nav_btn(
2158                        icons::CHEVRON_RIGHT,
2159                        nav_target(1),
2160                        format!("{base}-next"),
2161                        &next_focus,
2162                        next_disabled,
2163                        self.inert,
2164                        window,
2165                        cx,
2166                    )),
2167            );
2168            body = body.child(self.weekday_header(cx));
2169            let mut grid = gpui::div()
2170                .id(element_id::scoped(&self.id, "grid"))
2171                .flex()
2172                .flex_col();
2173            for row in calendar_view::week_aligned_rows(visible_start, visible_end, first_day) {
2174                let mut line = gpui::div().flex();
2175                for date in row {
2176                    line = line.child(match date {
2177                        Some(date) => self.day_cell(
2178                            date,
2179                            false,
2180                            &frame,
2181                            format!("{base}-{}", date.format_iso()),
2182                            cx,
2183                        ),
2184                        None => gpui::div().flex_1().h(px(36.)).into_any_element(),
2185                    });
2186                }
2187                grid = grid.child(line);
2188            }
2189            body = body.child(grid.a11y(a11y::Role::Grid));
2190        }
2191
2192        let mut viewport = gpui::div()
2193            .relative()
2194            .child(body.when(year_picker_open, |body| body.invisible()));
2195        if year_picker_open {
2196            viewport = viewport.child(self.year_grid(
2197                calendar_view::YearGridView {
2198                    years: &years,
2199                    active_year,
2200                    base: &self.id,
2201                    scroll: &year_scroll,
2202                    reveal_row: reveal_year_row,
2203                },
2204                &year_focus,
2205                &active_heading_focus,
2206                year_picker_own.clone(),
2207                window,
2208                cx,
2209            ));
2210        }
2211        root = root.child(viewport);
2212
2213        if self.is_disabled {
2214            root = root.opacity(layout.disabled_opacity);
2215        }
2216
2217        root = crate::util::apply_sx(root, &self.sx);
2218        // `useCalendarBase` is `role: 'application'`, named by the visible
2219        // range description. Stated after the layout chain.
2220        let app_name = if let Some(&(year, month)) = months.first() {
2221            self.month_heading_text(year, month)
2222        } else {
2223            calendar_view::range_heading(&linear)
2224        };
2225        root = root.a11y_named(a11y::Role::Application, &a11y::Name::labelled(app_name));
2226        root = crate::util::record_focus_bounds(root, &grid_focus, window, cx);
2227
2228        if columns > 1 {
2229            let heading = heading_focuses
2230                .iter()
2231                .position(|focus| focus.is_focused(window))
2232                .or_else(|| prev_focus.is_focused(window).then_some(0))
2233                .or_else(|| next_focus.is_focused(window).then_some(columns - 1));
2234            let reveal = heading
2235                .map(|index| {
2236                    let left = (column_width + px(32.)) * index as f32;
2237                    (
2238                        calendar_view::MonthScrollFocus::Heading(index),
2239                        left,
2240                        left + column_width,
2241                    )
2242                })
2243                .or_else(|| {
2244                    if year_picker_open {
2245                        years
2246                            .iter()
2247                            .position(|year| *year == active_year)
2248                            .map(|index| {
2249                                let width =
2250                                    column_width * columns as f32 + px(32.) * (columns - 1) as f32;
2251                                // The year grid has 4px padding and two 4px column gaps.
2252                                let cell = (width - px(16.)) / 3.;
2253                                let left = px(4.) + (cell + px(4.)) * (index % 3) as f32;
2254                                (
2255                                    calendar_view::MonthScrollFocus::Year(active_year),
2256                                    left,
2257                                    left + cell,
2258                                )
2259                            })
2260                    } else {
2261                        let date = focused_value.or(ring_at)?;
2262                        let (year, month, _) = self.system().from_gregorian(date);
2263                        let index = months.iter().position(|value| *value == (year, month))?;
2264                        let column = (weekday_index(date) + 7 - first_day.monday_index()) % 7;
2265                        let left = (column_width + px(32.)) * index as f32
2266                            + frame.cell_size * column as f32;
2267                        Some((
2268                            calendar_view::MonthScrollFocus::Day(date),
2269                            left,
2270                            left + frame.cell_size,
2271                        ))
2272                    }
2273                });
2274            calendar_view::scrolling_months(
2275                root.flex_shrink_0().mx_auto().into_any_element(),
2276                &self.id,
2277                reveal,
2278                window,
2279                cx,
2280            )
2281        } else {
2282            root.into_any_element()
2283        }
2284    }
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289    #[test]
2290    fn calendar_heading_keeps_the_day_that_identifies_a_non_gregorian_month() {
2291        assert_eq!(
2292            date_heading_for_locale("en-US-u-ca-indian", Date::new(2025, 12, 22)),
2293            month_heading_for_locale("en-US-u-ca-indian", 2026, 1)
2294        );
2295        assert!(
2296            date_heading_for_locale("en-US-u-ca-hebrew", Date::new(2024, 3, 11))
2297                .unwrap()
2298                .contains("Adar II")
2299        );
2300    }
2301
2302    #[test]
2303    fn a_locale_extension_moves_the_grid_into_that_calendar() {
2304        use crate::calendar_system::CalendarSystem;
2305        let system = CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap();
2306
2307        // The anchor stays Gregorian -- it is the caller's state -- and the
2308        // grid is addressed in the Indian calendar it converts to.
2309        let anchor = Date::new(2026, 1, 15);
2310        let months = calendar_view::month_headings_in(&system, VisibleDuration::Months(1), anchor);
2311        assert_eq!(months, vec![(1947, 10)], "January 2026 is Pausha 1947");
2312
2313        // Every cell of that month converts back to a real Gregorian date,
2314        // which is what selection and the constraints see.
2315        let days = system.days_in_month(1947, 10);
2316        assert_eq!(days, 30);
2317        for day in 1..=days {
2318            assert!(
2319                system.to_gregorian(1947, 10, day).is_some(),
2320                "Pausha {day} must be a date"
2321            );
2322        }
2323        assert_eq!(system.to_gregorian(1947, 10, 25), Some(anchor));
2324    }
2325
2326    #[test]
2327    fn the_heading_names_the_view_calendars_month() {
2328        // ICU formats an ISO date in whatever calendar the locale names, so the
2329        // Indian heading is the Indian month and its Saka year.
2330        let heading = month_heading_for_locale("hi-IN-u-ca-indian", 2026, 1).unwrap();
2331        assert!(
2332            heading.contains("1947"),
2333            "an Indian heading names the Saka year, got {heading:?}"
2334        );
2335        assert_ne!(
2336            heading,
2337            month_heading_for_locale("en-US", 2026, 1).unwrap(),
2338            "the two calendars must not head the same month identically"
2339        );
2340    }
2341
2342    #[test]
2343    fn month_names_follow_the_locale() {
2344        assert_eq!(month_names_for_locale("en-US").unwrap()[0], "January");
2345        assert_eq!(month_names_for_locale("de-DE").unwrap()[0], "Januar");
2346        assert_eq!(month_names_for_locale("fr-FR").unwrap()[0], "janvier");
2347    }
2348
2349    #[test]
2350    fn month_abbreviations_come_from_cldr_not_a_byte_slice() {
2351        assert_eq!(month_abbrs_for_locale("en-US").unwrap()[0], "Jan");
2352        // The first three *bytes* of this name are one character, so the old
2353        // `[..3]` slice would have panicked rather than abbreviated.
2354        let japanese = month_abbrs_for_locale("ja-JP").unwrap();
2355        assert!(japanese[0].chars().count() < japanese[0].len());
2356    }
2357
2358    #[test]
2359    fn the_heading_takes_its_field_order_from_the_locale() {
2360        let english = month_heading_for_locale("en-US", 2026, 1).unwrap();
2361        assert_eq!(english, "January 2026");
2362        assert_eq!(
2363            month_heading_for_locale("de-DE", 2026, 1).unwrap(),
2364            "Januar 2026"
2365        );
2366        // Japanese writes the year first, which a "{month} {year}" template
2367        // cannot reproduce whatever names it is given.
2368        let japanese = month_heading_for_locale("ja-JP", 2026, 1).unwrap();
2369        assert!(
2370            japanese.starts_with("2026"),
2371            "expected a year-first heading, got {japanese}"
2372        );
2373    }
2374
2375    #[test]
2376    fn an_unknown_locale_reports_nothing_rather_than_guessing() {
2377        assert!(month_names_for_locale("not a locale").is_none());
2378        assert!(month_heading_for_locale("not a locale", 2026, 1).is_none());
2379    }
2380    use super::*;
2381
2382    #[test]
2383    fn add_months_wraps_the_year_both_ways() {
2384        assert_eq!(add_months(2026, 8, 1), (2026, 9));
2385        assert_eq!(add_months(2026, 12, 1), (2027, 1));
2386        assert_eq!(add_months(2026, 1, -1), (2025, 12));
2387    }
2388
2389    #[test]
2390    fn add_months_counts_rather_than_stepping() {
2391        // shift+Page Up is a *year*: `bump_month` moved one month whatever the
2392        // magnitude, so this is the difference the calendar depends on.
2393        assert_eq!(add_months(2026, 8, -12), (2025, 8));
2394        assert_eq!(add_months(2026, 8, 12), (2027, 8));
2395        assert_eq!(add_months(2026, 8, -20), (2024, 12));
2396    }
2397
2398    // `Date::today` must be the local zone's civil date (v3
2399    // `today(getLocalTimeZone())`), not UTC's. `civil_date_at` is the
2400    // deterministic seam, so the crossings are proven with fixed instants and
2401    // offsets instead of the machine timezone. 2024-03-10T02:30:00Z is
2402    // 1710037800 and 2024-01-01T00:00:00Z is 1704067200.
2403
2404    #[test]
2405    fn utc_offset_west_moves_today_to_the_previous_day() {
2406        // 2024-03-10T02:30 UTC is 2024-03-09T19:30 at UTC-07:00 ...
2407        assert_eq!(civil_date_at(1_710_037_800, -25_200), Date::new(2024, 3, 9));
2408        // ... and 2024-01-01T02:00 UTC is 2023-12-31T21:00 at UTC-05:00.
2409        assert_eq!(
2410            civil_date_at(1_704_074_400, -18_000),
2411            Date::new(2023, 12, 31)
2412        );
2413    }
2414
2415    #[test]
2416    fn utc_offset_east_moves_today_to_the_next_day() {
2417        // 2024-01-01T15:00 UTC is 2024-01-02T00:00 at UTC+09:00 ...
2418        assert_eq!(civil_date_at(1_704_121_200, 32_400), Date::new(2024, 1, 2));
2419        // ... and 2023-12-31T22:00 UTC is 2024-01-01T01:00 at UTC+03:00.
2420        assert_eq!(civil_date_at(1_704_060_000, 10_800), Date::new(2024, 1, 1));
2421    }
2422
2423    #[test]
2424    fn utc_offset_day_crossings_survive_leap_and_year_boundaries() {
2425        // 2024-02-28T20:00 UTC at UTC+05:00 lands on leap day 2024-02-29 ...
2426        assert_eq!(civil_date_at(1_709_150_400, 18_000), Date::new(2024, 2, 29));
2427        // ... and 2024-02-29T20:00 UTC at UTC+05:00 lands on 2024-03-01.
2428        assert_eq!(civil_date_at(1_709_236_800, 18_000), Date::new(2024, 3, 1));
2429    }
2430
2431    #[test]
2432    fn fractional_offsets_resolve_midnight_aligned_local_dates() {
2433        // 2024-06-01T18:20 UTC is 2024-06-02T00:05 at Nepal's UTC+05:45.
2434        assert_eq!(civil_date_at(1_717_266_000, 20_700), Date::new(2024, 6, 2));
2435    }
2436
2437    #[test]
2438    fn pre_epoch_instants_carry_the_crossing_too() {
2439        // 1969-12-31T23:00 UTC is 1970-01-01T01:00 at UTC+02:00.
2440        assert_eq!(civil_date_at(-3_600, 7_200), Date::new(1970, 1, 1));
2441    }
2442
2443    #[test]
2444    fn today_reads_the_live_local_zone() {
2445        // Whatever zone this machine is in, `today` may only land on the UTC
2446        // date itself or on one of its neighbours; the old UTC-only derivation
2447        // and a hardcoded zone both break this bound.
2448        let now = jiff::Zoned::now();
2449        let utc_date = civil_date_at(now.timestamp().as_second(), 0);
2450        let diff = days_from_civil(&Date::today()) - days_from_civil(&utc_date);
2451        assert!(
2452            diff.abs() <= 1,
2453            "today {} vs UTC date {}",
2454            Date::today().format_iso(),
2455            utc_date.format_iso()
2456        );
2457    }
2458
2459    // The pinned `.calendar__cell:hover` (not selected) fills with
2460    // `bg-default`, the full token -- the same fill as the pressed state.
2461    // `soft_hover()` is a lighter, wrong wash, so the check is mechanical.
2462    #[test]
2463    fn the_day_cell_hovers_the_full_default() {
2464        // Scan the implementation only; this test's own text names the
2465        // forbidden accessor.
2466        let source = include_str!("calendar.rs")
2467            .split("#[cfg(test)]")
2468            .next()
2469            .expect("the implementation section is always present");
2470        assert!(
2471            source.contains(
2472                "let hover_bg = self.day_hover_bg.unwrap_or(colors.default.color);\n                let pressed_bg = hover_bg;"
2473            ),
2474            "the day cell must default to the full `bg-default` and honor \
2475             the named override (pinned \
2476             `.calendar__cell:hover:not([data-selected])`)"
2477        );
2478        assert!(
2479            !source.contains("colors.default.soft()"),
2480            "the day cell must not hover a soft token"
2481        );
2482    }
2483
2484    // The pinned `[data-today]` cell fills `bg-accent-soft` with
2485    // `text-accent-soft-foreground` and hovers `bg-accent-soft-hover`; the old
2486    // port invented an accent border instead.
2487    #[test]
2488    fn the_today_cell_uses_the_accent_soft_tokens() {
2489        // Scan the implementation only; this test's own text names the
2490        // forbidden accessor.
2491        let source = include_str!("calendar.rs")
2492            .split("#[cfg(test)]")
2493            .next()
2494            .expect("the implementation section is always present");
2495        assert!(
2496            source.contains(".bg(accent.soft())")
2497                && source.contains(".text_color(accent.soft_foreground(colors.foreground))"),
2498            "the today cell must fill `bg-accent-soft` with \
2499             `text-accent-soft-foreground` (pinned `.calendar__cell[data-today]`)"
2500        );
2501        assert!(
2502            source.contains("let hover_bg = accent.soft_hover();"),
2503            "the today cell must hover `bg-accent-soft-hover` \
2504             (pinned `.calendar__cell[data-today]:hover`)"
2505        );
2506        assert!(
2507            !source.contains("circle.border_1()"),
2508            "the today cell must not invent an accent border"
2509        );
2510    }
2511
2512    // gpui's `active` refinement overwrites the previous one, so chaining
2513    // `.active` after `anim::pressed` would drop the 0.95 press scale. The
2514    // pressed background must merge with the geometry in one refinement,
2515    // which is what `pressed_with_background` exists for.
2516    #[test]
2517    fn the_day_cell_press_merges_background_with_the_scale() {
2518        // Scan the implementation only; this test's own text names the
2519        // forbidden chaining.
2520        let source = include_str!("calendar.rs")
2521            .split("#[cfg(test)]")
2522            .next()
2523            .expect("the implementation section is always present");
2524        assert_eq!(
2525            source
2526                .matches("crate::anim::pressed_with_background(")
2527                .count(),
2528            3,
2529            "every pressable day branch must merge the pressed background \
2530             with the press geometry in one refinement"
2531        );
2532        assert!(
2533            !source.contains(".active("),
2534            "a chained `.active` after `anim::pressed` replaces the pressed \
2535             scale (gpui overwrites the active refinement)"
2536        );
2537    }
2538
2539    // The pinned `.calendar-year-picker__trigger` has no hover rule at all:
2540    // only focus and the open state recolour it. A soft wash looks plausible
2541    // on screen, so the check is mechanical.
2542    #[test]
2543    fn the_year_picker_trigger_hovers_nothing() {
2544        // Scan the implementation only; this test's own text names the
2545        // forbidden accessor.
2546        let source = include_str!("calendar.rs")
2547            .split("#[cfg(test)]")
2548            .next()
2549            .expect("the implementation section is always present");
2550        assert!(
2551            !source.contains("colors.default.soft_hover()"),
2552            "the year-picker trigger must not invent a hover background"
2553        );
2554    }
2555
2556    #[test]
2557    fn the_year_picker_indicator_rotates_one_down_chevron() {
2558        let source = include_str!("calendar.rs")
2559            .split("#[cfg(test)]")
2560            .next()
2561            .expect("the implementation section is always present");
2562        assert_eq!(
2563            source
2564                .matches("crate::anim::rotating_indicator_with_angle_ease_out(")
2565                .count(),
2566            2,
2567            "both heading render paths must share the animated indicator"
2568        );
2569        assert_eq!(
2570            source
2571                .matches("crate::anim::YEAR_PICKER_INDICATOR_ANGLE")
2572                .count(),
2573            2,
2574            "the year picker must use the pinned 90-degree angle"
2575        );
2576        assert_eq!(
2577            source.matches(".path(icons::CHEVRON_DOWN)").count(),
2578            2,
2579            "open and closed states must reuse one down-chevron asset"
2580        );
2581        assert!(
2582            !source.contains("icons::CHEVRON_UP"),
2583            "the year picker must rotate one asset instead of swapping glyphs"
2584        );
2585    }
2586
2587    // The pinned `.calendar__nav-button:active` is a bare `transform:
2588    // scale(0.95)` with no background change -- the hover fill stays a separate
2589    // refinement, so the nav button uses the backgroundless `anim::pressed`
2590    // while the day cells keep their `pressed_with_background` merges.
2591    #[test]
2592    fn the_nav_button_presses_the_deep_scale_over_its_hover() {
2593        // Scan the implementation only; this test's own text names the
2594        // forbidden chaining.
2595        let source = include_str!("calendar.rs")
2596            .split("#[cfg(test)]")
2597            .next()
2598            .expect("the implementation section is always present");
2599        assert_eq!(
2600            source.matches("crate::anim::pressed(").count(),
2601            1,
2602            "the nav button must carry the backgroundless press (the pinned \
2603             `[data-pressed]` only scales), and the day cells keep \
2604             `pressed_with_background`"
2605        );
2606        assert!(
2607            source.contains("scale: crate::anim::PRESSED_SCALE_DEEP,"),
2608            "the nav button must press to v3's 0.95 scale"
2609        );
2610        assert_eq!(
2611            source.matches(".hover(move |s| s.bg(hover_bg))").count(),
2612            2,
2613            "the two day-cell branches keep their immediate hover endpoints"
2614        );
2615        assert!(
2616            source.contains("crate::anim::hover_fade_with_duration(")
2617                && source.contains("Some(100)"),
2618            "the nav button must interpolate its `bg-default` hover fill over \
2619             the pinned 100ms ease-out transition"
2620        );
2621    }
2622}
2623
2624crate::util::impl_component_styled!(Calendar);