Skip to main content

tessera_components/
date_picker.rs

1//! Date picker components for selecting dates and inclusive ranges.
2//!
3//! ## Usage
4//!
5//! Choose calendar dates or booking intervals in scheduling and form flows.
6use std::ops::RangeInclusive;
7
8use tessera_ui::{
9    AxisConstraint, CallbackWith, Color, ComputedData, Constraint, Dp, LayoutResult,
10    MeasurementError, Modifier, Px, PxPosition, RenderSlot, State, key,
11    layout::{MeasureScope, layout},
12    modifier::FocusModifierExt as _,
13    provide_context, remember, tessera,
14    time::{SystemTime, UNIX_EPOCH},
15    use_context,
16};
17
18use crate::{
19    alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
20    boxed::boxed,
21    column::column,
22    divider::horizontal_divider,
23    icon::icon,
24    icon_button::icon_button,
25    material_icons::filled,
26    modifier::{ModifierExt as _, Padding, SemanticsArgs},
27    res::I18N,
28    row::row,
29    scrollable::{ScrollBarBehavior, ScrollableController, scrollable},
30    shape_def::Shape,
31    spacer::spacer,
32    surface::surface,
33    text::text,
34    text_field::text_field,
35    text_input::TextInputController,
36    theme::{ContentColor, MaterialTheme},
37};
38
39use navigation::{CalendarFocus, CalendarKeyboard, CalendarOwner};
40
41pub use cell::{CalendarRangePosition, date_picker_day, date_picker_year};
42pub use docked::{DockedDatePickerView, docked_date_picker};
43pub use range::{DateRangePickerState, date_range_picker};
44
45mod cell;
46mod docked;
47mod focus;
48mod navigation;
49mod range;
50
51const DATE_COLUMNS: usize = 7;
52const DATE_ROWS: usize = 6;
53const DATE_CELL_SIZE: Dp = Dp(48.0);
54const DATE_PICKER_MAX_WIDTH: Dp = Dp(360.0);
55const DATE_VISUAL_SIZE: Dp = Dp(40.0);
56
57/// Days of the week in Monday-first order.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum Weekday {
60    /// Monday.
61    #[default]
62    Monday,
63    /// Tuesday.
64    Tuesday,
65    /// Wednesday.
66    Wednesday,
67    /// Thursday.
68    Thursday,
69    /// Friday.
70    Friday,
71    /// Saturday.
72    Saturday,
73    /// Sunday.
74    Sunday,
75}
76
77impl Weekday {
78    fn index_from_monday(self) -> i32 {
79        match self {
80            Weekday::Monday => 0,
81            Weekday::Tuesday => 1,
82            Weekday::Wednesday => 2,
83            Weekday::Thursday => 3,
84            Weekday::Friday => 4,
85            Weekday::Saturday => 5,
86            Weekday::Sunday => 6,
87        }
88    }
89
90    fn from_monday_index(index: i32) -> Self {
91        match index.rem_euclid(7) {
92            0 => Weekday::Monday,
93            1 => Weekday::Tuesday,
94            2 => Weekday::Wednesday,
95            3 => Weekday::Thursday,
96            4 => Weekday::Friday,
97            5 => Weekday::Saturday,
98            _ => Weekday::Sunday,
99        }
100    }
101}
102
103/// A calendar date expressed as year, month, and day.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct CalendarDate {
106    year: i32,
107    month: u8,
108    day: u8,
109}
110
111impl CalendarDate {
112    /// Creates a calendar date if the values are valid.
113    pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
114        if !(1..=12).contains(&month) {
115            return None;
116        }
117        let max_day = days_in_month(year, month);
118        if day == 0 || day > max_day {
119            return None;
120        }
121        Some(Self { year, month, day })
122    }
123
124    /// Returns the year.
125    pub fn year(&self) -> i32 {
126        self.year
127    }
128
129    /// Returns the month (1-12).
130    pub fn month(&self) -> u8 {
131        self.month
132    }
133
134    /// Returns the day of the month (1-31).
135    pub fn day(&self) -> u8 {
136        self.day
137    }
138
139    /// Returns the current date in UTC.
140    pub fn today() -> Self {
141        let duration = SystemTime::now()
142            .duration_since(UNIX_EPOCH)
143            .unwrap_or_default();
144        let days = (duration.as_secs() / 86_400) as i64;
145        let (year, month, day) = civil_from_days(days);
146        CalendarDate::new(year, month, day)
147            .unwrap_or_else(|| CalendarDate::new_unchecked(1970, 1, 1))
148    }
149
150    fn new_unchecked(year: i32, month: u8, day: u8) -> Self {
151        Self { year, month, day }
152    }
153}
154
155/// A year and month pair used for month navigation.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct YearMonth {
158    year: i32,
159    month: u8,
160}
161
162impl YearMonth {
163    /// Creates a year/month pair if the values are valid.
164    pub fn new(year: i32, month: u8) -> Option<Self> {
165        if !(1..=12).contains(&month) {
166            return None;
167        }
168        Some(Self { year, month })
169    }
170
171    /// Returns the year.
172    pub fn year(&self) -> i32 {
173        self.year
174    }
175
176    /// Returns the month (1-12).
177    pub fn month(&self) -> u8 {
178        self.month
179    }
180
181    /// Returns the date for this month at the provided day.
182    pub fn to_date(&self, day: u8) -> Option<CalendarDate> {
183        CalendarDate::new(self.year, self.month, day)
184    }
185
186    /// Adds or subtracts months, adjusting the year as needed.
187    pub fn add_months(&self, delta: i32) -> Self {
188        let total = self.year * 12 + (self.month as i32 - 1) + delta;
189        let year = total.div_euclid(12);
190        let month = (total.rem_euclid(12) + 1) as u8;
191        Self { year, month }
192    }
193
194    fn new_unchecked(year: i32, month: u8) -> Self {
195        Self { year, month }
196    }
197}
198
199/// Display modes supported by [`date_picker`].
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum DatePickerDisplayMode {
202    /// Calendar grid selection.
203    #[default]
204    Picker,
205    /// Manual input with stepper controls.
206    Input,
207}
208
209/// Calendar content displayed by a modal date picker.
210#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
211pub enum DatePickerView {
212    /// Show weekdays and dates for the displayed month.
213    #[default]
214    Calendar,
215    /// Show the scrollable year selection grid.
216    Year,
217}
218
219/// Defaults for date picker behavior.
220pub struct DatePickerDefaults;
221
222impl DatePickerDefaults {
223    /// Default selectable year range.
224    pub const YEAR_RANGE: RangeInclusive<i32> = 1900..=2100;
225}
226
227/// Holds the current selection and display state for a date picker.
228///
229/// `is_selectable_date` and `is_selectable_year` are optional predicates.
230/// `None` means every date or year is selectable. A date is selectable only
231/// when it is inside the year range and both predicates allow it.
232///
233/// [`CallbackWith`] must be created during a component build, so these
234/// setters have to be called from inside a `#[tessera]` component. Closures
235/// passed to [`date_picker`] are turned into callbacks at that component
236/// boundary.
237pub struct DatePickerState {
238    selected_date: Option<CalendarDate>,
239    displayed_month: YearMonth,
240    year_range: RangeInclusive<i32>,
241    is_selectable_date: Option<CallbackWith<CalendarDate, bool>>,
242    is_selectable_year: Option<CallbackWith<i32, bool>>,
243    display_mode: DatePickerDisplayMode,
244}
245
246impl DatePickerState {
247    /// Creates a date picker state.
248    ///
249    /// `is_selectable_date` and `is_selectable_year` are optional. `None`
250    /// allows every date or year. When a callback is supplied, it must already
251    /// have been created during a component build.
252    pub fn new(
253        initial_selected_date: Option<CalendarDate>,
254        initial_displayed_month: Option<YearMonth>,
255        year_range: RangeInclusive<i32>,
256        is_selectable_date: Option<CallbackWith<CalendarDate, bool>>,
257        is_selectable_year: Option<CallbackWith<i32, bool>>,
258        display_mode: DatePickerDisplayMode,
259    ) -> Self {
260        let year_range = normalize_year_range(year_range);
261        let selected_date = initial_selected_date.filter(|date| {
262            is_date_selectable(
263                *date,
264                &year_range,
265                is_selectable_date.as_ref(),
266                is_selectable_year.as_ref(),
267            )
268        });
269
270        let displayed_month = initial_displayed_month
271            .filter(|month| year_range.contains(&month.year()))
272            .or_else(|| selected_date.and_then(|date| YearMonth::new(date.year(), date.month())))
273            .unwrap_or_else(|| fallback_displayed_month(&year_range));
274
275        Self {
276            selected_date,
277            displayed_month,
278            year_range,
279            is_selectable_date,
280            is_selectable_year,
281            display_mode,
282        }
283    }
284
285    /// Returns the selected date, if any.
286    pub fn selected_date(&self) -> Option<CalendarDate> {
287        self.selected_date
288    }
289
290    /// Returns the month currently displayed by the picker.
291    pub fn displayed_month(&self) -> YearMonth {
292        self.displayed_month
293    }
294
295    /// Returns the display mode.
296    pub fn display_mode(&self) -> DatePickerDisplayMode {
297        self.display_mode
298    }
299
300    /// Returns the year range allowed by this picker.
301    pub fn year_range(&self) -> &RangeInclusive<i32> {
302        &self.year_range
303    }
304
305    /// Returns the date predicate, if one is set.
306    ///
307    /// `None` means every date is selectable, subject to the year range and
308    /// year predicate.
309    pub fn is_selectable_date(&self) -> Option<CallbackWith<CalendarDate, bool>> {
310        self.is_selectable_date
311    }
312
313    /// Returns the year predicate, if one is set.
314    ///
315    /// `None` means every year is selectable, subject to the year range.
316    pub fn is_selectable_year(&self) -> Option<CallbackWith<i32, bool>> {
317        self.is_selectable_year
318    }
319
320    /// Sets the selected date if it is allowed.
321    pub fn set_selected_date(&mut self, date: CalendarDate) -> bool {
322        if !is_date_selectable(
323            date,
324            &self.year_range,
325            self.is_selectable_date.as_ref(),
326            self.is_selectable_year.as_ref(),
327        ) {
328            return false;
329        }
330        self.selected_date = Some(date);
331        if let Some(month) = YearMonth::new(date.year(), date.month()) {
332            self.displayed_month = clamp_month_to_range(month, &self.year_range);
333        }
334        true
335    }
336
337    /// Clears the selected date.
338    pub fn clear_selected_date(&mut self) {
339        self.selected_date = None;
340    }
341
342    /// Updates the displayed month, clamped to the year range.
343    pub fn set_displayed_month(&mut self, month: YearMonth) {
344        self.displayed_month = clamp_month_to_range(month, &self.year_range);
345    }
346
347    /// Moves the displayed month forward by one, staying within the year range.
348    pub fn next_month(&mut self) {
349        if can_navigate_next(self.displayed_month, &self.year_range) {
350            self.displayed_month = self.displayed_month.add_months(1);
351        }
352    }
353
354    /// Moves the displayed month backward by one, staying within the year
355    /// range.
356    pub fn previous_month(&mut self) {
357        if can_navigate_prev(self.displayed_month, &self.year_range) {
358            self.displayed_month = self.displayed_month.add_months(-1);
359        }
360    }
361
362    /// Updates the display mode.
363    pub fn set_display_mode(&mut self, mode: DatePickerDisplayMode) {
364        self.display_mode = mode;
365    }
366
367    /// Toggles between picker and input modes.
368    pub fn toggle_display_mode(&mut self) {
369        self.display_mode = match self.display_mode {
370            DatePickerDisplayMode::Picker => DatePickerDisplayMode::Input,
371            DatePickerDisplayMode::Input => DatePickerDisplayMode::Picker,
372        };
373    }
374
375    /// Updates the allowed year range.
376    pub fn set_year_range(&mut self, year_range: RangeInclusive<i32>) {
377        self.year_range = normalize_year_range(year_range);
378        self.displayed_month = clamp_month_to_range(self.displayed_month, &self.year_range);
379        self.clear_unselectable_date();
380    }
381
382    /// Updates the date predicate.
383    ///
384    /// `None` means every date is selectable. A callback must be created during
385    /// a component build, so call this setter from inside a `#[tessera]`
386    /// component.
387    pub fn set_is_selectable_date(
388        &mut self,
389        is_selectable_date: Option<CallbackWith<CalendarDate, bool>>,
390    ) {
391        self.is_selectable_date = is_selectable_date;
392        self.clear_unselectable_date();
393    }
394
395    /// Updates the year predicate.
396    ///
397    /// `None` means every year is selectable. A callback must be created during
398    /// a component build, so call this setter from inside a `#[tessera]`
399    /// component.
400    pub fn set_is_selectable_year(&mut self, is_selectable_year: Option<CallbackWith<i32, bool>>) {
401        self.is_selectable_year = is_selectable_year;
402        self.clear_unselectable_date();
403    }
404
405    fn clear_unselectable_date(&mut self) {
406        if let Some(date) = self.selected_date
407            && !is_date_selectable(
408                date,
409                &self.year_range,
410                self.is_selectable_date.as_ref(),
411                self.is_selectable_year.as_ref(),
412            )
413        {
414            self.selected_date = None;
415        }
416    }
417
418    fn snapshot(&self) -> DatePickerSnapshot {
419        DatePickerSnapshot {
420            selected_date: self.selected_date,
421            displayed_month: self.displayed_month,
422            year_range: self.year_range.clone(),
423            is_selectable_date: self.is_selectable_date,
424            is_selectable_year: self.is_selectable_year,
425            display_mode: self.display_mode,
426        }
427    }
428}
429
430impl Default for DatePickerState {
431    fn default() -> Self {
432        DatePickerState::new(
433            None,
434            None,
435            DatePickerDefaults::YEAR_RANGE,
436            None,
437            None,
438            DatePickerDisplayMode::Picker,
439        )
440    }
441}
442
443#[derive(Clone, PartialEq)]
444struct DatePickerSnapshot {
445    selected_date: Option<CalendarDate>,
446    displayed_month: YearMonth,
447    year_range: RangeInclusive<i32>,
448    is_selectable_date: Option<CallbackWith<CalendarDate, bool>>,
449    is_selectable_year: Option<CallbackWith<i32, bool>>,
450    display_mode: DatePickerDisplayMode,
451}
452
453#[derive(Clone, PartialEq)]
454struct DatePickerConfig {
455    modifier: Modifier,
456    today: CalendarDate,
457    first_day_of_week: Weekday,
458    show_weekday_labels: bool,
459    show_mode_toggle: bool,
460    title: Option<String>,
461    headline: Option<String>,
462    state: Option<State<DatePickerState>>,
463    view: Option<State<DatePickerView>>,
464    focus_requester: Option<tessera_ui::FocusRequester>,
465}
466
467/// # date_picker
468///
469/// Render a calendar date picker for selecting a single date.
470///
471/// ## Usage
472///
473/// Use when you need a calendar grid for picking a specific date.
474///
475/// ## Parameters
476///
477/// - `modifier` — modifier chain applied to the date picker.
478/// - `initial_selected_date` — initial selected date for the internal state.
479/// - `initial_displayed_month` — initial displayed month for the internal
480///   state.
481/// - `year_range` — year range allowed in the internal state.
482/// - `is_selectable_date` — optional date predicate for the internal state.
483///   `None` allows every date. A date is selectable only when the year range,
484///   the year predicate, and this predicate all allow it.
485/// - `is_selectable_year` — optional year predicate for the internal state.
486///   `None` allows every year.
487/// - `display_mode` — initial display mode for the internal state.
488/// - `first_day_of_week` — first day of the week for the calendar grid.
489/// - `today` — current date used for date and year outlines and initial
490///   keyboard focus; defaults to the system date.
491/// - `show_weekday_labels` — whether weekday labels are rendered.
492/// - `show_mode_toggle` — whether the display mode toggle is shown.
493/// - `title` — optional override for the title text.
494/// - `headline` — optional override for the headline text.
495/// - `state` — optional external state for selection, month navigation, and
496///   mode.
497/// - `view` — optional shared calendar or year selection view.
498/// - `focus_requester` — requests focus on the calendar's current keyboard date.
499///
500/// ## Examples
501///
502/// ```
503/// # use tessera_ui::tessera;
504/// # #[tessera]
505/// # fn component() {
506/// use tessera_components::date_picker::{CalendarDate, DatePickerState, date_picker};
507/// # use tessera_components::theme::{MaterialTheme, material_theme};
508///
509/// # material_theme()
510/// #     .theme(|| MaterialTheme::default())
511/// #     .child(|| {
512/// date_picker().today(CalendarDate::new(2026, 7, 16).unwrap());
513///
514/// let mut state = DatePickerState::default();
515/// assert!(state.selected_date().is_none());
516/// #     });
517/// # }
518/// # tessera_ui::testing::layout_test(component).run();
519/// ```
520#[tessera]
521pub fn date_picker(
522    modifier: Option<Modifier>,
523    initial_selected_date: Option<CalendarDate>,
524    initial_displayed_month: Option<YearMonth>,
525    year_range: Option<RangeInclusive<i32>>,
526    is_selectable_date: Option<CallbackWith<CalendarDate, bool>>,
527    is_selectable_year: Option<CallbackWith<i32, bool>>,
528    display_mode: Option<DatePickerDisplayMode>,
529    first_day_of_week: Option<Weekday>,
530    today: Option<CalendarDate>,
531    show_weekday_labels: Option<bool>,
532    show_mode_toggle: Option<bool>,
533    #[prop(into)] title: Option<String>,
534    #[prop(into)] headline: Option<String>,
535    state: Option<State<DatePickerState>>,
536    view: Option<State<DatePickerView>>,
537    focus_requester: Option<tessera_ui::FocusRequester>,
538) {
539    let today = today.unwrap_or_else(CalendarDate::today);
540    let modifier = modifier.unwrap_or_default();
541    let display_mode = display_mode.unwrap_or_default();
542    let first_day_of_week = first_day_of_week.unwrap_or_default();
543    let show_weekday_labels = show_weekday_labels.unwrap_or(true);
544    let show_mode_toggle = show_mode_toggle.unwrap_or(true);
545    let year_range = year_range.unwrap_or(DatePickerDefaults::YEAR_RANGE);
546    let state = if let Some(state) = state {
547        state
548    } else {
549        // Resolve predicates before remember borrows the slot table for
550        // initialization.
551        let initial = DatePickerState::new(
552            initial_selected_date,
553            initial_displayed_month.or_else(|| {
554                initial_selected_date
555                    .and_then(|date| YearMonth::new(date.year(), date.month()))
556                    .or_else(|| Some(fallback_displayed_month_at(&year_range, today)))
557            }),
558            year_range,
559            is_selectable_date,
560            is_selectable_year,
561            display_mode,
562        );
563        remember(|| initial)
564    };
565    date_picker_content(DatePickerConfig {
566        modifier,
567        today,
568        first_day_of_week,
569        show_weekday_labels,
570        show_mode_toggle,
571        title,
572        headline,
573        state: Some(state),
574        view,
575        focus_requester,
576    });
577}
578
579#[tessera]
580fn date_picker_content(args: DatePickerConfig) {
581    let state = args.state.expect("date_picker_content requires state");
582    let snapshot = state.with(DatePickerState::snapshot);
583    let theme = use_context::<MaterialTheme>().unwrap().get();
584    let scheme = theme.color_scheme;
585    let title = args
586        .title
587        .unwrap_or_else(|| default_title(snapshot.display_mode));
588    let headline = args.headline.unwrap_or_else(|| {
589        if snapshot.display_mode == DatePickerDisplayMode::Input && snapshot.selected_date.is_none()
590        {
591            I18N.text("md3-input-date")
592        } else {
593            default_headline(snapshot.selected_date)
594        }
595    });
596    column()
597        .modifier(
598            args.modifier
599                .width(DATE_PICKER_MAX_WIDTH)
600                .semantics(SemanticsArgs {
601                    role: Some(tessera_ui::accesskit::Role::Group),
602                    label: Some(title.clone()),
603                    merge_descendants: true,
604                    test_tag: cfg!(test).then(|| "date-picker".to_owned()),
605                    ..Default::default()
606                }),
607        )
608        .children(move || {
609            let title = title.clone();
610            let headline = headline.clone();
611            boxed().children(move || {
612                let title = title.clone();
613                let headline = headline.clone();
614                column()
615                    .modifier(
616                        Modifier::new()
617                            .fill_max_width()
618                            .height(Dp(120.0))
619                            .padding(Padding::new(Dp(24.0), Dp(16.0), Dp(12.0), Dp(12.0))),
620                    )
621                    .children(move || {
622                        text(title.clone())
623                            .style(theme.typography.label_large)
624                            .color(scheme.on_surface_variant);
625                        spacer().modifier(Modifier::new().height(Dp(24.0)));
626                        let headline = headline.clone();
627                        row()
628                            .modifier(Modifier::new().fill_max_width().height(Dp(48.0)))
629                            .main_axis_alignment(MainAxisAlignment::SpaceBetween)
630                            .cross_axis_alignment(CrossAxisAlignment::Center)
631                            .children(move || {
632                                text(headline.clone())
633                                    .style(theme.typography.headline_large)
634                                    .color(scheme.on_surface);
635                                if args.show_mode_toggle {
636                                    date_mode_toggle(state);
637                                }
638                            });
639                    });
640                column()
641                    .modifier(Modifier::new().fill_max_width().height(Dp(120.0)))
642                    .main_axis_alignment(MainAxisAlignment::End)
643                    .children(move || {
644                        horizontal_divider().color(scheme.outline_variant);
645                    });
646            });
647            match snapshot.display_mode {
648                DatePickerDisplayMode::Picker => {
649                    calendar_view(
650                        state,
651                        args.first_day_of_week,
652                        args.show_weekday_labels,
653                        args.today,
654                    )
655                    .view_optional(args.view)
656                    .focus_requester_optional(args.focus_requester);
657                }
658                DatePickerDisplayMode::Input => {
659                    date_input_view(state);
660                }
661            }
662        });
663}
664
665/// # date_picker_dialog
666///
667/// Render a date picker dialog body with optional action buttons.
668///
669/// ## Usage
670///
671/// Use inside `dialog_provider` with `.padding(Dp(0.0))` for modal date
672/// selection.
673///
674/// ## Parameters
675///
676/// - `dialog` — registration scope received from the provider content slot.
677/// - `state` — state handle used by the embedded date picker.
678/// - `title` — optional override for the dialog title.
679/// - `confirm_button` — optional confirm button content.
680/// - `dismiss_button` — optional dismiss button content.
681/// - `picker_modifier` — modifier chain applied to the embedded picker.
682/// - `picker_first_day_of_week` — first day of the week for the embedded
683///   picker.
684/// - `picker_today` — current date used for outlines and initial keyboard
685///   focus; defaults to the system date.
686/// - `picker_show_weekday_labels` — whether weekday labels are rendered.
687/// - `picker_show_mode_toggle` — whether the display mode toggle is shown.
688/// - `picker_title` — optional picker title override.
689/// - `picker_headline` — optional picker headline override.
690/// - `picker_view` — optional shared calendar or year selection view.
691///
692/// ## Examples
693///
694/// ```
695/// # use tessera_ui::tessera;
696/// # #[tessera]
697/// # fn component() {
698/// use tessera_components::date_picker::{CalendarDate, DatePickerState, date_picker_dialog};
699/// use tessera_ui::remember;
700/// # use tessera_components::theme::{MaterialTheme, material_theme};
701///
702/// # material_theme()
703/// #     .theme(|| MaterialTheme::default())
704/// #     .child(|| {
705/// let state = remember(DatePickerState::default);
706/// date_picker_dialog()
707///     .state(state)
708///     .picker_today(CalendarDate::new(2026, 7, 16).unwrap());
709/// #     });
710/// # }
711/// # tessera_ui::testing::layout_test(component).run();
712/// ```
713#[tessera]
714pub fn date_picker_dialog(
715    dialog: Option<crate::dialog::DialogScope>,
716    state: Option<State<DatePickerState>>,
717    #[prop(into)] title: Option<String>,
718    confirm_button: Option<RenderSlot>,
719    dismiss_button: Option<RenderSlot>,
720    picker_modifier: Option<Modifier>,
721    picker_first_day_of_week: Option<Weekday>,
722    picker_today: Option<CalendarDate>,
723    picker_show_weekday_labels: Option<bool>,
724    picker_show_mode_toggle: Option<bool>,
725    #[prop(into)] picker_title: Option<String>,
726    #[prop(into)] picker_headline: Option<String>,
727    picker_view: Option<State<DatePickerView>>,
728    picker_focus_requester: Option<tessera_ui::FocusRequester>,
729) {
730    let today = picker_today.unwrap_or_else(CalendarDate::today);
731    let state = if let Some(state) = state {
732        state
733    } else {
734        remember(|| {
735            DatePickerState::new(
736                None,
737                Some(fallback_displayed_month_at(
738                    &DatePickerDefaults::YEAR_RANGE,
739                    today,
740                )),
741                DatePickerDefaults::YEAR_RANGE,
742                None,
743                None,
744                DatePickerDisplayMode::Picker,
745            )
746        })
747    };
748    let title = picker_title.or(title);
749    crate::dialog::register_dialog_accessibility()
750        .dialog_optional(dialog)
751        .label(
752            title
753                .clone()
754                .unwrap_or_else(|| I18N.text("md3-select-date")),
755        );
756    let theme = use_context::<MaterialTheme>().unwrap().get();
757    column()
758        .modifier(Modifier::new().width(DATE_PICKER_MAX_WIDTH))
759        .children(move || {
760            date_picker_content(DatePickerConfig {
761                modifier: picker_modifier.clone().unwrap_or_default(),
762                today,
763                first_day_of_week: picker_first_day_of_week.unwrap_or_default(),
764                show_weekday_labels: picker_show_weekday_labels.unwrap_or(true),
765                show_mode_toggle: picker_show_mode_toggle.unwrap_or(true),
766                title: title.clone(),
767                headline: picker_headline.clone(),
768                state: Some(state),
769                view: picker_view,
770                focus_requester: picker_focus_requester,
771            });
772            if confirm_button.is_some() || dismiss_button.is_some() {
773                provide_context(
774                    || ContentColor {
775                        current: theme.color_scheme.primary,
776                    },
777                    || {
778                        row()
779                            .modifier(
780                                Modifier::new()
781                                    .fill_max_width()
782                                    .height(Dp(60.0))
783                                    .padding(Padding::new(Dp(12.0), Dp(4.0), Dp(12.0), Dp(8.0))),
784                            )
785                            .main_axis_alignment(MainAxisAlignment::End)
786                            .cross_axis_alignment(CrossAxisAlignment::Center)
787                            .children(move || {
788                                if let Some(dismiss) = dismiss_button {
789                                    dismiss.render();
790                                }
791                                if confirm_button.is_some() && dismiss_button.is_some() {
792                                    spacer().modifier(Modifier::new().width(Dp(8.0)));
793                                }
794                                if let Some(confirm) = confirm_button {
795                                    confirm.render();
796                                }
797                            });
798                    },
799                );
800            }
801        });
802}
803
804#[tessera]
805fn calendar_view(
806    state: State<DatePickerState>,
807    first_day_of_week: Weekday,
808    show_weekday_labels: bool,
809    today: CalendarDate,
810    view: Option<State<DatePickerView>>,
811    focus_requester: Option<tessera_ui::FocusRequester>,
812) {
813    let snapshot = state.with(DatePickerState::snapshot);
814    let view = if let Some(view) = view {
815        view
816    } else {
817        remember(DatePickerView::default)
818    };
819    let can_prev = can_navigate_prev(snapshot.displayed_month, &snapshot.year_range);
820    let can_next = can_navigate_next(snapshot.displayed_month, &snapshot.year_range);
821    let theme = use_context::<MaterialTheme>().unwrap().get();
822    let scheme = theme.color_scheme;
823    column()
824        .modifier(
825            Modifier::new()
826                .fill_max_width()
827                .padding_symmetric(Dp(12.0), Dp(0.0)),
828        )
829        .children(move || {
830            row()
831                .modifier(Modifier::new().fill_max_width().height(Dp(56.0)))
832                .main_axis_alignment(MainAxisAlignment::SpaceBetween)
833                .cross_axis_alignment(CrossAxisAlignment::Center)
834                .children(move || {
835                    surface()
836                        .modifier(
837                            Modifier::new()
838                                .height(Dp(48.0))
839                                .padding_symmetric(Dp(12.0), Dp(0.0)),
840                        )
841                        .shape(Shape::CAPSULE)
842                        .style(Color::TRANSPARENT.into())
843                        .content_alignment(Alignment::Center)
844                        .on_click(move || {
845                            view.with_mut(|value| {
846                                *value = if *value == DatePickerView::Calendar {
847                                    DatePickerView::Year
848                                } else {
849                                    DatePickerView::Calendar
850                                }
851                            })
852                        })
853                        .accessibility_label(I18N.text("md3-choose-year"))
854                        .child(move || {
855                            row()
856                                .cross_axis_alignment(CrossAxisAlignment::Center)
857                                .children(move || {
858                                    text(format_month_year(snapshot.displayed_month))
859                                        .style(theme.typography.label_large)
860                                        .color(scheme.on_surface_variant);
861                                    spacer().modifier(Modifier::new().width(Dp(8.0)));
862                                    icon()
863                                        .painter(if view.get() == DatePickerView::Year {
864                                            filled::ARROW_DROP_UP_SVG
865                                        } else {
866                                            filled::ARROW_DROP_DOWN_SVG
867                                        })
868                                        .tint(scheme.on_surface_variant);
869                                });
870                        });
871                    if view.get() != DatePickerView::Year {
872                        row().children(move || {
873                            boxed()
874                                .modifier(Modifier::new().size(Dp(48.0), Dp(48.0)))
875                                .alignment(Alignment::Center)
876                                .children(move || {
877                                    icon_button()
878                                        .standard()
879                                        .icon(filled::CHEVRON_LEFT_SVG)
880                                        .enabled(can_prev)
881                                        .accessibility_label(I18N.text("md3-previous-month"))
882                                        .on_click(move || {
883                                            state.with_mut(DatePickerState::previous_month)
884                                        });
885                                });
886                            boxed()
887                                .modifier(Modifier::new().size(Dp(48.0), Dp(48.0)))
888                                .alignment(Alignment::Center)
889                                .children(move || {
890                                    icon_button()
891                                        .standard()
892                                        .icon(filled::CHEVRON_RIGHT_SVG)
893                                        .enabled(can_next)
894                                        .accessibility_label(I18N.text("md3-next-month"))
895                                        .on_click(move || {
896                                            state.with_mut(DatePickerState::next_month)
897                                        });
898                                });
899                        });
900                    } else {
901                        spacer().modifier(Modifier::new().size(Dp(96.0), Dp(48.0)));
902                    }
903                });
904            if view.get() == DatePickerView::Year {
905                date_year_grid(state, view, today);
906            } else {
907                if show_weekday_labels {
908                    layout()
909                        .policy(DateGridLayout {
910                            columns: DATE_COLUMNS,
911                            row_height: DATE_CELL_SIZE,
912                        })
913                        .child(move || {
914                            for weekday in weekday_sequence(first_day_of_week) {
915                                surface()
916                                    .style(Color::TRANSPARENT.into())
917                                    .modifier(Modifier::new().size(DATE_CELL_SIZE, DATE_CELL_SIZE))
918                                    .content_alignment(Alignment::Center)
919                                    .child(move || {
920                                        text(weekday_narrow_label(weekday))
921                                            .style(theme.typography.body_large)
922                                            .color(scheme.on_surface);
923                                    });
924                            }
925                        });
926                }
927                date_grid(state, first_day_of_week, today)
928                    .focus_requester_optional(focus_requester);
929            }
930        });
931}
932
933#[tessera]
934fn date_grid(
935    state: State<DatePickerState>,
936    first_day_of_week: Weekday,
937    today: CalendarDate,
938    focus_requester: Option<tessera_ui::FocusRequester>,
939) {
940    let snapshot = state.with(DatePickerState::snapshot);
941    let owner = CalendarOwner::Single(state);
942    let initial = owner.initial_date(today);
943    let focus = remember(move || CalendarFocus::new(initial, focus_requester));
944    let keyboard = CalendarKeyboard {
945        focus,
946        owner,
947        first_day: first_day_of_week,
948    };
949    keyboard.sync(today);
950    let grid = build_month_grid(snapshot.displayed_month, first_day_of_week);
951    let on_select = CallbackWith::new(move |date| {
952        state.with_mut(|state| state.set_selected_date(date));
953    });
954    layout()
955        .policy(DateGridLayout {
956            columns: DATE_COLUMNS,
957            row_height: DATE_CELL_SIZE,
958        })
959        .modifier(
960            Modifier::new()
961                .focus_restorer(Some(keyboard.focus_requester()))
962                .semantics(SemanticsArgs {
963                    role: Some(tessera_ui::accesskit::Role::Group),
964                    label: Some(format_month_year(snapshot.displayed_month)),
965                    merge_descendants: true,
966                    test_tag: cfg!(test).then(|| "date-grid".to_owned()),
967                    ..Default::default()
968                }),
969        )
970        .child(move || {
971            for cell in grid.iter().copied() {
972                let enabled = cell.is_some_and(|date| {
973                    is_date_selectable(
974                        date,
975                        &snapshot.year_range,
976                        snapshot.is_selectable_date.as_ref(),
977                        snapshot.is_selectable_year.as_ref(),
978                    )
979                });
980                calendar_day(on_select, today)
981                    .cell_optional(cell)
982                    .selected(cell.is_some() && snapshot.selected_date == cell)
983                    .enabled(enabled)
984                    .keyboard(keyboard);
985            }
986        });
987}
988
989#[derive(Clone, PartialEq)]
990struct DateGridLayout {
991    columns: usize,
992    row_height: Dp,
993}
994
995impl tessera_ui::LayoutModifierNode for DateGridLayout {
996    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
997        let children = input.children();
998        let width = input.parent_constraint().width().clamp(Dp(336.0).to_px());
999        let row_height = self.row_height.to_px();
1000        let height = row_height * children.len().div_ceil(self.columns) as i32;
1001        let mut result = LayoutResult::new(ComputedData { width, height });
1002        for (index, child) in children.into_iter().enumerate() {
1003            let column = index % self.columns;
1004            let x = Px(width.0 * column as i32 / self.columns as i32);
1005            let end = Px(width.0 * (column + 1) as i32 / self.columns as i32);
1006            child.measure(
1007                input,
1008                &Constraint::new(
1009                    AxisConstraint::exact(end - x),
1010                    AxisConstraint::exact(row_height),
1011                ),
1012            )?;
1013            result.place_child(
1014                child,
1015                PxPosition::new(x, row_height * (index / self.columns) as i32),
1016            );
1017        }
1018        Ok(result)
1019    }
1020}
1021
1022#[tessera]
1023fn calendar_day(
1024    cell: Option<CalendarDate>,
1025    selected: Option<bool>,
1026    enabled: Option<bool>,
1027    in_range: Option<bool>,
1028    outside_month: Option<bool>,
1029    #[prop(into)] description: Option<String>,
1030    on_select: CallbackWith<CalendarDate>,
1031    today: CalendarDate,
1032    keyboard: Option<CalendarKeyboard>,
1033    range_band: Option<CalendarRangeBand>,
1034) {
1035    if let Some(date) = cell {
1036        let modifier = keyboard.map_or_else(Modifier::new, |keyboard| {
1037            keyboard.day_modifier(Modifier::new(), date)
1038        });
1039        let range_position = range_band.map(|band| match (band.start, band.end) {
1040            (true, true) => CalendarRangePosition::Isolated,
1041            (true, false) => CalendarRangePosition::Start,
1042            (false, false) => CalendarRangePosition::Middle,
1043            (false, true) => CalendarRangePosition::End,
1044        });
1045        date_picker_day(date)
1046            .on_select_shared(on_select)
1047            .selected_optional(selected)
1048            .enabled_optional(enabled)
1049            .in_range_optional(in_range)
1050            .outside_month_optional(outside_month)
1051            .is_today(date == today)
1052            .modifier(modifier)
1053            .focus_requester_optional(keyboard.and_then(|keyboard| keyboard.requester(date)))
1054            .range_position_optional(range_position)
1055            .accessibility_description_optional(description);
1056    } else {
1057        spacer().modifier(Modifier::new().size(DATE_CELL_SIZE, DATE_CELL_SIZE));
1058    }
1059}
1060
1061#[derive(Clone, Copy, PartialEq)]
1062struct CalendarRangeBand {
1063    start: bool,
1064    end: bool,
1065}
1066
1067#[tessera]
1068fn date_year_grid(state: State<DatePickerState>, view: State<DatePickerView>, today: CalendarDate) {
1069    let snapshot = state.with(DatePickerState::snapshot);
1070    let first = *snapshot.year_range.start();
1071    let count = (*snapshot.year_range.end() as i64 - first as i64 + 1) as usize;
1072    let rows = count.div_ceil(3);
1073    let scroll = remember(|| {
1074        let mut controller = ScrollableController::new();
1075        let row = ((snapshot.displayed_month.year() as i64 - first as i64) / 3)
1076            .saturating_sub(3)
1077            .max(0);
1078        controller.set_scroll_position(PxPosition::new(Px::ZERO, -Dp(row as f64 * 52.0).to_px()));
1079        controller
1080    });
1081    let offset = scroll.with(|c| -c.child_position().y);
1082    let start = (((offset - Dp(8.0).to_pixels_f32()).max(0.0) / Dp(52.0).to_pixels_f32())
1083        .floor()
1084        .max(0.0) as usize)
1085        .min(rows.saturating_sub(1));
1086    let end = (start + 8).min(rows);
1087    scrollable()
1088        .modifier(
1089            Modifier::new()
1090                .size(Dp(336.0), Dp(288.0))
1091                .semantics(SemanticsArgs {
1092                    role: Some(tessera_ui::accesskit::Role::Group),
1093                    label: Some(I18N.text("md3-choose-year")),
1094                    merge_descendants: true,
1095                    test_tag: cfg!(test).then(|| "date-years".to_owned()),
1096                    ..Default::default()
1097                }),
1098        )
1099        .vertical(true)
1100        .horizontal(false)
1101        .scrollbar_behavior(ScrollBarBehavior::Hidden)
1102        .controller(scroll)
1103        .child(move || {
1104            column()
1105                .modifier(Modifier::new().width(Dp(336.0)))
1106                .children(move || {
1107                    spacer().modifier(Modifier::new().height(Dp(8.0 + start as f64 * 52.0)));
1108                    layout()
1109                        .policy(DateGridLayout {
1110                            columns: 3,
1111                            row_height: Dp(52.0),
1112                        })
1113                        .child(move || {
1114                            for index in start * 3..(end * 3).min(count) {
1115                                let year = (first as i64 + index as i64) as i32;
1116                                let selected = snapshot.displayed_month.year() == year;
1117                                let enabled = snapshot
1118                                    .is_selectable_year
1119                                    .as_ref()
1120                                    .is_none_or(|predicate| predicate.call(year));
1121                                let today = today.year() == year;
1122                                key(year, move || {
1123                                    date_picker_year(year)
1124                                        .selected(selected)
1125                                        .is_today(today)
1126                                        .enabled(enabled)
1127                                        .on_select(move |year: i32| {
1128                                            state.with_mut(|s| {
1129                                                s.set_displayed_month(YearMonth::new_unchecked(
1130                                                    year,
1131                                                    s.displayed_month.month(),
1132                                                ))
1133                                            });
1134                                            view.set(DatePickerView::Calendar);
1135                                        });
1136                                });
1137                            }
1138                        });
1139                    spacer().modifier(Modifier::new().height(Dp((rows - end) as f64 * 52.0)));
1140                });
1141        });
1142}
1143
1144#[tessera]
1145fn date_input_view(state: State<DatePickerState>) {
1146    let snapshot = state.with(DatePickerState::snapshot);
1147    let theme = use_context::<MaterialTheme>().unwrap().get();
1148    let controller = remember(|| {
1149        let mut controller = TextInputController::new(Dp(16.0), Some(Dp(24.0)));
1150        if let Some(date) = snapshot.selected_date {
1151            controller.set_text(&format_date_input(date));
1152        }
1153        controller
1154    });
1155    let last_selected = remember(|| snapshot.selected_date);
1156    let previous = remember(|| {
1157        snapshot
1158            .selected_date
1159            .map(format_date_input)
1160            .unwrap_or_default()
1161    });
1162    if last_selected.get() != snapshot.selected_date {
1163        last_selected.set(snapshot.selected_date);
1164        let expected = snapshot
1165            .selected_date
1166            .map(format_date_input)
1167            .unwrap_or_default();
1168        if controller.with(|c| c.text()) != expected {
1169            controller.with_mut(|c| c.set_text(&expected));
1170            previous.set(expected);
1171        }
1172    }
1173    let value = controller.with(|c| c.text());
1174    let error = if value.is_empty() {
1175        None
1176    } else {
1177        match parse_date_input(&value) {
1178            None => Some(I18N.text("md3-enter-a-date-as-mm-dd-yyyy")),
1179            Some(date)
1180                if !is_date_selectable(
1181                    date,
1182                    &snapshot.year_range,
1183                    snapshot.is_selectable_date.as_ref(),
1184                    snapshot.is_selectable_year.as_ref(),
1185                ) =>
1186            {
1187                Some(I18N.text("md3-date-is-not-allowed"))
1188            }
1189            Some(_) => None,
1190        }
1191    };
1192    column()
1193        .modifier(Modifier::new().fill_max_width().padding_all(Dp(24.0)))
1194        .children(move || {
1195            text_field()
1196                .outlined()
1197                .modifier(Modifier::new().fill_max_width().height(Dp(56.0)))
1198                .background_color(theme.color_scheme.surface_container_high)
1199                .focus_background_color(theme.color_scheme.surface_container_high)
1200                .controller(controller)
1201                .label(I18N.text("md3-date"))
1202                .placeholder("MM/DD/YYYY")
1203                .is_error(error.is_some())
1204                .on_change(move |value| {
1205                    if value.len() > 10 || !value.bytes().all(|c| c.is_ascii_digit() || c == b'/') {
1206                        return previous.get();
1207                    }
1208                    state.with_mut(|s| {
1209                        s.clear_selected_date();
1210                        if let Some(date) = parse_date_input(&value) {
1211                            s.set_selected_date(date);
1212                        }
1213                    });
1214                    last_selected.set(state.with(DatePickerState::selected_date));
1215                    previous.set(value.clone());
1216                    value
1217                });
1218            if let Some(error) = &error {
1219                text(error.clone())
1220                    .style(theme.typography.body_small)
1221                    .color(theme.color_scheme.error);
1222            }
1223        });
1224}
1225
1226#[tessera]
1227fn date_mode_toggle(state: State<DatePickerState>) {
1228    let input = state.with(|s| s.display_mode == DatePickerDisplayMode::Input);
1229    icon_button()
1230        .standard()
1231        .icon(if input {
1232            filled::CALENDAR_TODAY_SVG
1233        } else {
1234            filled::EDIT_SVG
1235        })
1236        .accessibility_label(if input {
1237            I18N.text("md3-switch-to-calendar")
1238        } else {
1239            I18N.text("md3-switch-to-date-input")
1240        })
1241        .on_click(move || state.with_mut(DatePickerState::toggle_display_mode));
1242}
1243
1244fn format_date_input(date: CalendarDate) -> String {
1245    format!("{:02}/{:02}/{:04}", date.month(), date.day(), date.year())
1246}
1247
1248fn parse_date_input(value: &str) -> Option<CalendarDate> {
1249    if value.len() != 10
1250        || !value.is_ascii()
1251        || value.as_bytes()[2] != b'/'
1252        || value.as_bytes()[5] != b'/'
1253    {
1254        return None;
1255    }
1256    CalendarDate::new(
1257        value[6..].parse().ok()?,
1258        value[..2].parse().ok()?,
1259        value[3..5].parse().ok()?,
1260    )
1261}
1262
1263fn default_title(mode: DatePickerDisplayMode) -> String {
1264    I18N.text(match mode {
1265        DatePickerDisplayMode::Picker => "md3-select-date",
1266        DatePickerDisplayMode::Input => "md3-enter-date",
1267    })
1268}
1269
1270fn default_headline(selected: Option<CalendarDate>) -> String {
1271    selected
1272        .map(format_selected_date)
1273        .unwrap_or_else(|| I18N.text("md3-select-date"))
1274}
1275
1276fn format_selected_date(date: CalendarDate) -> String {
1277    I18N.format(
1278        "md3-date-headline",
1279        [
1280            (
1281                "weekday",
1282                weekday_short_label(weekday_for_date(date)).into(),
1283            ),
1284            ("month", format_month_short_name(date.month()).into()),
1285            ("day", date.day().into()),
1286        ],
1287    )
1288}
1289
1290fn weekday_narrow_label(day: Weekday) -> String {
1291    I18N.text(match day {
1292        Weekday::Monday => "md3-weekday-mon-narrow",
1293        Weekday::Tuesday => "md3-weekday-tue-narrow",
1294        Weekday::Wednesday => "md3-weekday-wed-narrow",
1295        Weekday::Thursday => "md3-weekday-thu-narrow",
1296        Weekday::Friday => "md3-weekday-fri-narrow",
1297        Weekday::Saturday => "md3-weekday-sat-narrow",
1298        Weekday::Sunday => "md3-weekday-sun-narrow",
1299    })
1300}
1301
1302fn format_month_year(month: YearMonth) -> String {
1303    I18N.format(
1304        "md3-month-year",
1305        [
1306            ("month", format_month_name(month.month()).into()),
1307            ("year", (month.year()).to_string().into()),
1308        ],
1309    )
1310}
1311
1312fn format_month_name(month: u8) -> String {
1313    I18N.text(match month {
1314        1 => "md3-january",
1315        2 => "md3-february",
1316        3 => "md3-march",
1317        4 => "md3-april",
1318        5 => "md3-may",
1319        6 => "md3-june",
1320        7 => "md3-july",
1321        8 => "md3-august",
1322        9 => "md3-september",
1323        10 => "md3-october",
1324        11 => "md3-november",
1325        _ => "md3-december",
1326    })
1327}
1328
1329fn format_month_short_name(month: u8) -> String {
1330    I18N.text(match month {
1331        1 => "md3-jan",
1332        2 => "md3-feb",
1333        3 => "md3-mar",
1334        4 => "md3-apr",
1335        5 => "md3-may",
1336        6 => "md3-jun",
1337        7 => "md3-jul",
1338        8 => "md3-aug",
1339        9 => "md3-sep",
1340        10 => "md3-oct",
1341        11 => "md3-nov",
1342        _ => "md3-dec",
1343    })
1344}
1345
1346fn weekday_sequence(first_day_of_week: Weekday) -> [Weekday; DATE_COLUMNS] {
1347    let mut days = [Weekday::Monday; DATE_COLUMNS];
1348    let start = first_day_of_week.index_from_monday();
1349    for (idx, slot) in days.iter_mut().enumerate() {
1350        *slot = Weekday::from_monday_index(start + idx as i32);
1351    }
1352    days
1353}
1354
1355fn weekday_short_label(day: Weekday) -> String {
1356    I18N.text(match day {
1357        Weekday::Monday => "md3-mon",
1358        Weekday::Tuesday => "md3-tue",
1359        Weekday::Wednesday => "md3-wed",
1360        Weekday::Thursday => "md3-thu",
1361        Weekday::Friday => "md3-fri",
1362        Weekday::Saturday => "md3-sat",
1363        Weekday::Sunday => "md3-sun",
1364    })
1365}
1366
1367fn build_month_grid(month: YearMonth, first_day_of_week: Weekday) -> Vec<Option<CalendarDate>> {
1368    let mut cells = vec![None; DATE_COLUMNS * DATE_ROWS];
1369    let first_date = month.to_date(1);
1370    let first_date =
1371        first_date.unwrap_or_else(|| CalendarDate::new_unchecked(month.year(), month.month(), 1));
1372    let first_weekday = weekday_for_date(first_date);
1373    let offset = (first_weekday.index_from_monday() - first_day_of_week.index_from_monday())
1374        .rem_euclid(7) as usize;
1375    let max_day = days_in_month(month.year(), month.month());
1376    for day in 1..=max_day {
1377        let index = offset + day as usize - 1;
1378        if index < cells.len() {
1379            cells[index] = CalendarDate::new(month.year(), month.month(), day);
1380        }
1381    }
1382    cells
1383}
1384
1385fn is_date_selectable(
1386    date: CalendarDate,
1387    year_range: &RangeInclusive<i32>,
1388    is_selectable_date: Option<&CallbackWith<CalendarDate, bool>>,
1389    is_selectable_year: Option<&CallbackWith<i32, bool>>,
1390) -> bool {
1391    let year_allowed = is_selectable_year
1392        .map(|predicate| predicate.call(date.year()))
1393        .unwrap_or(true);
1394    let date_allowed = is_selectable_date
1395        .map(|predicate| predicate.call(date))
1396        .unwrap_or(true);
1397    year_range.contains(&date.year()) && year_allowed && date_allowed
1398}
1399
1400fn normalize_year_range(range: RangeInclusive<i32>) -> RangeInclusive<i32> {
1401    let start = *range.start();
1402    let end = *range.end();
1403    if start <= end { range } else { end..=start }
1404}
1405
1406fn fallback_displayed_month(year_range: &RangeInclusive<i32>) -> YearMonth {
1407    fallback_displayed_month_at(year_range, CalendarDate::today())
1408}
1409
1410fn fallback_displayed_month_at(year_range: &RangeInclusive<i32>, today: CalendarDate) -> YearMonth {
1411    let year = if year_range.contains(&today.year()) {
1412        today.year()
1413    } else {
1414        *year_range.start()
1415    };
1416    let month = if year_range.contains(&today.year()) {
1417        today.month()
1418    } else {
1419        1
1420    };
1421    YearMonth::new(year, month).unwrap_or_else(|| YearMonth::new_unchecked(year, 1))
1422}
1423
1424fn clamp_month_to_range(month: YearMonth, year_range: &RangeInclusive<i32>) -> YearMonth {
1425    let start = *year_range.start();
1426    let end = *year_range.end();
1427    if month.year() < start {
1428        YearMonth::new_unchecked(start, 1)
1429    } else if month.year() > end {
1430        YearMonth::new_unchecked(end, 12)
1431    } else {
1432        month
1433    }
1434}
1435
1436fn can_navigate_prev(month: YearMonth, year_range: &RangeInclusive<i32>) -> bool {
1437    let start = *year_range.start();
1438    month.year() > start || (month.year() == start && month.month() > 1)
1439}
1440
1441fn can_navigate_next(month: YearMonth, year_range: &RangeInclusive<i32>) -> bool {
1442    let end = *year_range.end();
1443    month.year() < end || (month.year() == end && month.month() < 12)
1444}
1445
1446fn weekday_for_date(date: CalendarDate) -> Weekday {
1447    let days = days_from_civil(date.year(), date.month(), date.day());
1448    let index = (days + 3).rem_euclid(7) as i32;
1449    Weekday::from_monday_index(index)
1450}
1451
1452fn days_in_month(year: i32, month: u8) -> u8 {
1453    match month {
1454        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1455        4 | 6 | 9 | 11 => 30,
1456        2 if is_leap_year(year) => 29,
1457        2 => 28,
1458        _ => 30,
1459    }
1460}
1461
1462fn is_leap_year(year: i32) -> bool {
1463    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
1464}
1465
1466fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
1467    let mut y = i64::from(year);
1468    let m = i64::from(month);
1469    let d = i64::from(day);
1470    y -= if m <= 2 { 1 } else { 0 };
1471    let era = if y >= 0 { y } else { y - 399 } / 400;
1472    let yoe = y - era * 400;
1473    let mp = m + if m > 2 { -3 } else { 9 };
1474    let doy = (153 * mp + 2) / 5 + d - 1;
1475    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1476    era * 146_097 + doe - 719_468
1477}
1478
1479fn civil_from_days(days: i64) -> (i32, u8, u8) {
1480    let z = days + 719_468;
1481    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1482    let doe = z - era * 146_097;
1483    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1484    let y = yoe + era * 400;
1485    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1486    let mp = (5 * doy + 2) / 153;
1487    let day = doy - (153 * mp + 2) / 5 + 1;
1488    let month = mp + if mp < 10 { 3 } else { -9 };
1489    let year = y + if month <= 2 { 1 } else { 0 };
1490    (year as i32, month as u8, day as u8)
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495    use tessera_ui::testing::{text_matches, visible_text};
1496
1497    use tessera_ui::{
1498        CursorEventContent, GestureState, PointerChange, PressKeyEventType, PxPosition, State,
1499        remember, tessera,
1500        testing::{
1501            layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
1502            start_layout_test_session,
1503        },
1504    };
1505
1506    use crate::{button::button, column::column, text::text, theme::material_theme};
1507
1508    use super::{
1509        CalendarDate, DatePickerState, DatePickerView, Weekday, YearMonth, date_picker,
1510        date_picker_dialog, format_date_input, parse_date_input,
1511    };
1512
1513    #[derive(Clone, Default, PartialEq)]
1514    struct Probe {
1515        state: tessera_ui::testing::TestProbe<Option<State<DatePickerState>>>,
1516        view: tessera_ui::testing::TestProbe<Option<State<DatePickerView>>>,
1517        today: tessera_ui::testing::TestProbe<Option<State<CalendarDate>>>,
1518        range: tessera_ui::testing::TestProbe<Option<State<super::DateRangePickerState>>>,
1519        action: tessera_ui::testing::TestProbe<u8>,
1520        focus: tessera_ui::testing::TestProbe<Option<tessera_ui::FocusRequester>>,
1521    }
1522
1523    #[tessera]
1524    fn today_fixture(range: bool, dialog: bool, probe: Probe) {
1525        let today = remember(|| CalendarDate::new(2026, 7, 16).unwrap());
1526        let state = remember(|| {
1527            DatePickerState::new(
1528                None,
1529                YearMonth::new(2026, 7),
1530                2025..=2028,
1531                None,
1532                None,
1533                Default::default(),
1534            )
1535        });
1536        let range_state = remember(|| {
1537            super::DateRangePickerState::new(
1538                None,
1539                None,
1540                YearMonth::new(2026, 7),
1541                2025..=2028,
1542                None,
1543                None,
1544                Default::default(),
1545            )
1546        });
1547        let view = remember(DatePickerView::default);
1548        let focus = remember(tessera_ui::FocusRequester::new).get();
1549        probe.focus.set(Some(focus));
1550        probe.today.set(Some(today));
1551        probe.range.set(Some(range_state));
1552        probe.state.set(Some(state));
1553        probe.view.set(Some(view));
1554        material_theme()
1555            .theme(|| {
1556                let mut theme = crate::theme::MaterialTheme::default();
1557                theme.color_scheme.primary = tessera_ui::Color::new(0.05, 0.15, 0.8, 1.0);
1558                theme
1559            })
1560            .child(move || {
1561                if range {
1562                    super::date_range_picker()
1563                        .state(range_state)
1564                        .focus_requester(focus)
1565                        .today(today.get())
1566                        .first_day_of_week(Weekday::Sunday);
1567                } else if dialog {
1568                    date_picker_dialog()
1569                        .state(state)
1570                        .picker_focus_requester(focus)
1571                        .picker_today(today.get())
1572                        .picker_view(view)
1573                        .picker_first_day_of_week(Weekday::Sunday);
1574                } else {
1575                    date_picker()
1576                        .state(state)
1577                        .focus_requester(focus)
1578                        .today(today.get())
1579                        .view(view)
1580                        .first_day_of_week(Weekday::Sunday);
1581                }
1582            });
1583    }
1584
1585    #[cfg(not(target_family = "wasm"))]
1586    fn assert_today_outline(
1587        renderer: &tessera_ui::HeadlessRenderer,
1588        label: &str,
1589        radius: f64,
1590        expected: bool,
1591    ) {
1592        let tree = renderer.accessibility_snapshot().unwrap();
1593        let node = tree
1594            .nodes
1595            .iter()
1596            .find(|(_, node)| {
1597                text_matches(node.label(), label)
1598                    && node.role() == tessera_ui::accesskit::Role::Button
1599            })
1600            .unwrap_or_else(|| panic!("missing actionable {label}"));
1601        let rect = node.1.bounds().expect("date target bounds");
1602        let x = (rect.x0 + rect.x1) / 2.0;
1603        let y = (rect.y0 + rect.y1) / 2.0;
1604        let pixels = renderer.read_pixels().unwrap();
1605        let width = renderer.physical_size().width as usize;
1606        let blue = ((y - radius - 1.0).floor() as usize..(y - radius + 4.0).ceil() as usize)
1607            .flat_map(|y| ((x - 8.0) as usize..(x + 8.0) as usize).map(move |x| (x, y)))
1608            .any(|(x, y)| {
1609                let offset = (y * width + x) * 4;
1610                let color = &pixels[offset..offset + 3];
1611                i32::from(color[2]) > i32::from(color[0]) + 60
1612                    && i32::from(color[2]) > i32::from(color[1]) + 60
1613            });
1614        assert_eq!(blue, expected, "current-date outline for {label}");
1615    }
1616
1617    #[cfg(not(target_family = "wasm"))]
1618    fn check_today_override(range: bool, dialog: bool) {
1619        use tessera_ui::{FocusManager, HeadlessConfig, HeadlessRenderer};
1620
1621        let observed = Probe::default();
1622
1623        let probe = observed.clone();
1624
1625        let mut renderer = HeadlessRenderer::new(
1626            Box::new(move || {
1627                today_fixture(range, dialog, probe.clone());
1628            }),
1629            vec![Box::new(crate::TesseraComponents)],
1630            HeadlessConfig {
1631                width: 400,
1632                height: 650,
1633                sample_count: 1,
1634                ..Default::default()
1635            },
1636        )
1637        .unwrap();
1638        renderer.render_frames(8);
1639        assert_today_outline(&renderer, "July 16, 2026", 20.0, true);
1640        assert_today_outline(&renderer, "July 17, 2026", 20.0, false);
1641        observed.focus.get().unwrap().request_focus();
1642        renderer.render_frames(4);
1643        let tree = renderer.accessibility_snapshot().unwrap();
1644        assert!(text_matches(
1645            tree.nodes
1646                .iter()
1647                .find(|(id, _)| *id == tree.focus)
1648                .and_then(|(_, node)| node.label()),
1649            "July 16, 2026"
1650        ));
1651        FocusManager::current().clear_focus(true);
1652        observed
1653            .today
1654            .get()
1655            .unwrap()
1656            .set(CalendarDate::new(2026, 7, 17).unwrap());
1657        renderer.render_frames(4);
1658        assert_today_outline(&renderer, "July 16, 2026", 20.0, false);
1659        assert_today_outline(&renderer, "July 17, 2026", 20.0, true);
1660        if !range {
1661            observed.view.get().unwrap().set(DatePickerView::Year);
1662            observed
1663                .today
1664                .get()
1665                .unwrap()
1666                .set(CalendarDate::new(2027, 7, 17).unwrap());
1667            renderer.render_frames(4);
1668            assert_today_outline(&renderer, "2027", 18.0, true);
1669            assert_today_outline(&renderer, "2028", 18.0, false);
1670            observed
1671                .today
1672                .get()
1673                .unwrap()
1674                .set(CalendarDate::new(2028, 7, 17).unwrap());
1675            renderer.render_frames(4);
1676            assert_today_outline(&renderer, "2027", 18.0, false);
1677            assert_today_outline(&renderer, "2028", 18.0, true);
1678            observed.view.get().unwrap().set(DatePickerView::Calendar);
1679            observed
1680                .state
1681                .get()
1682                .unwrap()
1683                .with_mut(|state| state.set_displayed_month(YearMonth::new(2028, 7).unwrap()));
1684            renderer.render_frames(4);
1685            observed.focus.get().unwrap().request_focus();
1686            renderer.render_frames(4);
1687            let tree = renderer.accessibility_snapshot().unwrap();
1688            assert!(text_matches(
1689                tree.nodes
1690                    .iter()
1691                    .find(|(id, _)| *id == tree.focus)
1692                    .and_then(|(_, node)| node.label()),
1693                "July 17, 2028"
1694            ));
1695        } else {
1696            observed
1697                .today
1698                .get()
1699                .unwrap()
1700                .set(CalendarDate::new(2028, 7, 17).unwrap());
1701            observed
1702                .range
1703                .get()
1704                .unwrap()
1705                .with_mut(|state| state.set_displayed_month(YearMonth::new(2028, 7).unwrap()));
1706            renderer.render_frames(8);
1707            observed.focus.get().unwrap().request_focus();
1708            renderer.render_frames(4);
1709            let tree = renderer.accessibility_snapshot().unwrap();
1710            assert!(text_matches(
1711                tree.nodes
1712                    .iter()
1713                    .find(|(id, _)| *id == tree.focus)
1714                    .and_then(|(_, node)| node.label()),
1715                "July 17, 2028"
1716            ));
1717        }
1718    }
1719
1720    #[test]
1721    #[ignore = "requires a native GPU adapter"]
1722    #[cfg(not(target_family = "wasm"))]
1723    fn gpu_today_override_controls_single_and_dialog_outlines_and_keyboard_fallback() {
1724        crate::test_utils::run_isolated_test(
1725            "date_picker::tests::gpu_today_override_controls_single_and_dialog_outlines_and_keyboard_fallback",
1726            || {
1727                check_today_override(false, false);
1728                check_today_override(false, true);
1729            },
1730        );
1731    }
1732
1733    #[test]
1734    #[ignore = "requires a native GPU adapter"]
1735    #[cfg(not(target_family = "wasm"))]
1736    fn gpu_today_override_controls_range_outline_and_keyboard_fallback() {
1737        crate::test_utils::run_isolated_test(
1738            "date_picker::tests::gpu_today_override_controls_range_outline_and_keyboard_fallback",
1739            || check_today_override(true, false),
1740        );
1741    }
1742
1743    #[tessera]
1744    fn external_year_fixture(probe: Probe) {
1745        let state = remember(|| {
1746            DatePickerState::new(
1747                CalendarDate::new(2025, 8, 17),
1748                YearMonth::new(2025, 8),
1749                2021..=2100,
1750                None,
1751                None,
1752                Default::default(),
1753            )
1754        });
1755        let view = remember(|| DatePickerView::Year);
1756        probe.state.set(Some(state));
1757        probe.view.set(Some(view));
1758        let on_action = tessera_ui::CallbackWith::new(move |value| probe.action.set(value));
1759        material_theme().child(move || {
1760            date_picker_dialog()
1761                .state(state)
1762                .picker_view(view)
1763                .confirm_button(move || {
1764                    button()
1765                        .text()
1766                        .accessibility_label("Confirm external view")
1767                        .on_click(move || on_action.call(1))
1768                        .child(|| {
1769                            text("OK");
1770                        });
1771                })
1772                .dismiss_button(move || {
1773                    button()
1774                        .text()
1775                        .accessibility_label("Cancel external view")
1776                        .on_click(move || on_action.call(2))
1777                        .child(|| {
1778                            text("Cancel");
1779                        });
1780                });
1781        });
1782    }
1783
1784    #[cfg(not(target_family = "wasm"))]
1785    #[test]
1786    #[ignore = "requires a native GPU adapter"]
1787    fn gpu_external_year_view_tracks_header_toggle_selection_and_dialog_actions() {
1788        use tessera_ui::{
1789            HeadlessConfig, HeadlessRenderer,
1790            accesskit::{Action, ActionRequest, TreeId},
1791        };
1792        fn activate(renderer: &mut HeadlessRenderer, label: &str) {
1793            let target_node = renderer
1794                .accessibility_snapshot()
1795                .unwrap()
1796                .nodes
1797                .into_iter()
1798                .find(|(_, node)| {
1799                    text_matches(node.label(), label) && node.supports_action(Action::Click)
1800                })
1801                .unwrap_or_else(|| panic!("missing actionable {label}"))
1802                .0;
1803            assert!(renderer.dispatch_accessibility_action(ActionRequest {
1804                action: Action::Click,
1805                target_tree: TreeId::ROOT,
1806                target_node,
1807                data: None
1808            }));
1809            renderer.render_frames(4);
1810        }
1811        let observed = Probe::default();
1812        let probe = observed.clone();
1813        let mut renderer = HeadlessRenderer::new(
1814            Box::new(move || external_year_fixture(probe.clone())),
1815            vec![Box::new(crate::TesseraComponents)],
1816            HeadlessConfig {
1817                width: 400,
1818                height: 650,
1819                sample_count: 1,
1820                ..Default::default()
1821            },
1822        )
1823        .unwrap();
1824        renderer.render_frames(4);
1825        let state = observed.state.get().unwrap();
1826        let view = observed.view.get().unwrap();
1827        let selected = CalendarDate::new(2025, 8, 17);
1828        let initial_tree = renderer.accessibility_snapshot().unwrap();
1829        assert!(
1830            initial_tree
1831                .nodes
1832                .iter()
1833                .any(|(_, node)| text_matches(node.label(), "2025")
1834                    && node.supports_action(Action::Click)),
1835            "initial Year view must expose its selected year as a named action: {:?}",
1836            initial_tree.nodes
1837        );
1838        let bounds = |label: &str| {
1839            initial_tree
1840                .nodes
1841                .iter()
1842                .find(|(_, node)| {
1843                    text_matches(node.label(), label) && node.supports_action(Action::Click)
1844                })
1845                .unwrap()
1846                .1
1847                .bounds()
1848                .unwrap()
1849        };
1850        let first = bounds("2021");
1851        let second = bounds("2022");
1852        let next_row = bounds("2024");
1853        assert_eq!((first.width(), first.height()), (112.0, 52.0));
1854        assert_eq!(second.x0 - first.x0, 112.0);
1855        assert_eq!(next_row.y0 - first.y0, 52.0);
1856        let viewport = initial_tree
1857            .nodes
1858            .iter()
1859            .find(|(_, node)| {
1860                text_matches(node.label(), "Choose year")
1861                    && node.bounds().is_some_and(|rect| rect.height() == 288.0)
1862            })
1863            .unwrap()
1864            .1
1865            .bounds()
1866            .unwrap();
1867        assert_eq!(viewport.width(), 336.0);
1868        assert_eq!(first.y0 - viewport.y0, 8.0);
1869        let confirm = bounds("Confirm external view");
1870        assert_eq!(confirm.height(), 48.0);
1871        assert_eq!((confirm.y0 + confirm.y1) / 2.0 - 20.0 - viewport.y1, 8.0);
1872        let target_node = initial_tree
1873            .nodes
1874            .iter()
1875            .find(|(_, node)| {
1876                text_matches(node.label(), "2039")
1877                    && node.role() == tessera_ui::accesskit::Role::Button
1878                    && node.supports_action(Action::Focus)
1879            })
1880            .expect("virtualized overscan must retain the next focusable rows")
1881            .0;
1882        assert!(renderer.dispatch_accessibility_action(ActionRequest {
1883            action: Action::Focus,
1884            target_tree: TreeId::ROOT,
1885            target_node,
1886            data: None
1887        }));
1888        renderer.render_frames(4);
1889        let focused_tree = renderer.accessibility_snapshot().unwrap();
1890        let focused = focused_tree
1891            .nodes
1892            .iter()
1893            .find(|(id, _)| *id == focused_tree.focus)
1894            .unwrap();
1895        assert!(text_matches(focused.1.label(), "2039"));
1896        let revealed = focused.1.bounds().unwrap();
1897        assert!(revealed.y0 >= viewport.y0 && revealed.y1 <= viewport.y1);
1898        assert_eq!(state.with(DatePickerState::selected_date), selected);
1899        activate(&mut renderer, "Choose year");
1900        assert_eq!(view.get(), DatePickerView::Calendar);
1901        assert!(
1902            renderer
1903                .accessibility_snapshot()
1904                .unwrap()
1905                .nodes
1906                .iter()
1907                .any(|(_, node)| text_matches(node.label(), "August 17, 2025"))
1908        );
1909        view.set(DatePickerView::Year);
1910        renderer.render_frames(4);
1911        activate(&mut renderer, "2027");
1912        assert_eq!(view.get(), DatePickerView::Calendar);
1913        assert_eq!(
1914            state.with(DatePickerState::displayed_month),
1915            YearMonth::new(2027, 8).unwrap()
1916        );
1917        assert_eq!(state.with(DatePickerState::selected_date), selected);
1918        activate(&mut renderer, "Confirm external view");
1919        assert_eq!(observed.action.get(), 1);
1920        activate(&mut renderer, "Cancel external view");
1921        assert_eq!(observed.action.get(), 2);
1922        assert_eq!(state.with(DatePickerState::selected_date), selected);
1923    }
1924
1925    #[test]
1926    fn date_input_validates_calendar_dates_and_requires_a_complete_format() {
1927        let leap_day = CalendarDate::new(2024, 2, 29).unwrap();
1928        assert_eq!(parse_date_input("02/29/2024"), Some(leap_day));
1929        assert_eq!(format_date_input(leap_day), "02/29/2024");
1930        for invalid in [
1931            "02/29/2023",
1932            "13/01/2024",
1933            "04/31/2024",
1934            "2/29/2024",
1935            "02/29/24",
1936            "02/29/2024",
1937            "02-29-2024",
1938        ] {
1939            assert_eq!(parse_date_input(invalid), None, "{invalid}");
1940        }
1941    }
1942
1943    #[tessera]
1944    fn calendar_fixture(probe: Probe, today: Option<CalendarDate>) {
1945        let state = remember(|| {
1946            DatePickerState::new(
1947                None,
1948                YearMonth::new(2026, 7),
1949                2020..=2030,
1950                None,
1951                None,
1952                Default::default(),
1953            )
1954        });
1955        probe.state.set(Some(state));
1956        let focus = remember(tessera_ui::FocusRequester::new).get();
1957        probe.focus.set(Some(focus));
1958        material_theme().child(move || {
1959            column().children(move || {
1960                date_picker()
1961                    .state(state)
1962                    .today(today.unwrap_or_else(|| CalendarDate::new(2026, 7, 1).unwrap()))
1963                    .focus_requester(focus)
1964                    .first_day_of_week(Weekday::Sunday);
1965                button()
1966                    .text()
1967                    .accessibility_label("After single calendar")
1968                    .on_click(|| {})
1969                    .child(|| {
1970                        text("After single calendar");
1971                    });
1972            });
1973        });
1974    }
1975
1976    #[cfg(not(target_family = "wasm"))]
1977    fn check_single_calendar_accessibility(samples: u32) {
1978        use tessera_ui::{
1979            FocusDirection, FocusManager, HeadlessConfig, HeadlessRenderer, MOUSE_POINTER_ID,
1980            accesskit::Role,
1981        };
1982
1983        let observed = Probe::default();
1984
1985        let probe = observed.clone();
1986
1987        let mut renderer = HeadlessRenderer::new(
1988            Box::new(move || {
1989                calendar_fixture(probe.clone()).today(CalendarDate::new(2026, 7, 16).unwrap())
1990            }),
1991            vec![Box::new(crate::TesseraComponents)],
1992            HeadlessConfig {
1993                width: 600,
1994                height: 950,
1995                scale_factor: 1.5,
1996                sample_count: samples,
1997                ..Default::default()
1998            },
1999        )
2000        .expect("single calendar renderer");
2001        renderer.render_frames(8);
2002        let accessibility = renderer.accessibility_snapshot().expect("tree");
2003        assert_eq!(
2004            accessibility
2005                .nodes
2006                .iter()
2007                .filter(|(_, node)| node.role() == Role::Button
2008                    && node.label().map(visible_text).is_some_and(|label| label
2009                        .starts_with("July ")
2010                        && label.ends_with(", 2026")))
2011                .count(),
2012            31,
2013            "the published calendar must expose every July date"
2014        );
2015        observed.focus.get().unwrap().request_focus();
2016        renderer.render_frames(8);
2017        let accessibility = renderer.accessibility_snapshot().expect("tree");
2018        assert!(text_matches(
2019            accessibility
2020                .nodes
2021                .iter()
2022                .find(|(id, _)| *id == accessibility.focus)
2023                .and_then(|(_, node)| node.label()),
2024            "July 16, 2026"
2025        ));
2026        assert!(FocusManager::current().request_move_focus(FocusDirection::Next));
2027        renderer.render_frames(8);
2028        let accessibility = renderer.accessibility_snapshot().expect("tree");
2029        assert!(text_matches(
2030            accessibility
2031                .nodes
2032                .iter()
2033                .find(|(id, _)| *id == accessibility.focus)
2034                .and_then(|(_, node)| node.label()),
2035            "After single calendar"
2036        ));
2037        let state = observed.state.get().expect("single state");
2038        assert_eq!(state.with(DatePickerState::selected_date), None);
2039        for label in ["Choose year", "2027"] {
2040            let accessibility = renderer.accessibility_snapshot().expect("tree");
2041            let bounds = accessibility
2042                .nodes
2043                .iter()
2044                .find(|(_, node)| text_matches(node.label(), label) && node.bounds().is_some())
2045                .expect("published year target")
2046                .1
2047                .bounds()
2048                .unwrap();
2049            let point = PxPosition::from_f32_xy(
2050                ((bounds.x0 + bounds.x1) / 2.0) as f32,
2051                ((bounds.y0 + bounds.y1) / 2.0) as f32,
2052            );
2053            renderer.pointer_move(MOUSE_POINTER_ID, point);
2054            renderer.pointer_press(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
2055            renderer.step_frame();
2056            renderer.pointer_release(MOUSE_POINTER_ID, point, PressKeyEventType::Left);
2057            renderer.render_frames(8);
2058        }
2059        assert_eq!(
2060            state.with(DatePickerState::displayed_month),
2061            YearMonth::new(2027, 7).unwrap()
2062        );
2063        assert_eq!(state.with(DatePickerState::selected_date), None);
2064        assert!(
2065            renderer
2066                .accessibility_snapshot()
2067                .expect("tree")
2068                .nodes
2069                .iter()
2070                .any(|(_, node)| text_matches(node.label(), "July 1, 2027"))
2071        );
2072        state.with_mut(|state| state.set_display_mode(super::DatePickerDisplayMode::Input));
2073        renderer.render_frames(8);
2074        assert_eq!(
2075            renderer
2076                .accessibility_snapshot()
2077                .expect("tree")
2078                .nodes
2079                .iter()
2080                .filter(|(_, node)| node.role() == Role::TextInput && node.bounds().is_some())
2081                .count(),
2082            1,
2083            "manual entry must publish its editable input"
2084        );
2085    }
2086
2087    #[test]
2088    #[ignore = "requires a native GPU adapter"]
2089    #[cfg(not(target_family = "wasm"))]
2090    fn gpu_single_calendar_publishes_dates_focus_and_year_actions() {
2091        crate::test_utils::run_isolated_test(
2092            "date_picker::tests::gpu_single_calendar_publishes_dates_focus_and_year_actions",
2093            || check_single_calendar_accessibility(1),
2094        );
2095    }
2096
2097    #[test]
2098    #[ignore = "requires a native GPU adapter"]
2099    #[cfg(not(target_family = "wasm"))]
2100    fn gpu_single_calendar_publishes_dates_focus_and_year_actions_msaa() {
2101        crate::test_utils::run_isolated_test(
2102            "date_picker::tests::gpu_single_calendar_publishes_dates_focus_and_year_actions_msaa",
2103            || check_single_calendar_accessibility(4),
2104        );
2105    }
2106
2107    #[tessera]
2108    fn internal_predicate_fixture() {
2109        material_theme().child(|| {
2110            column().children(|| {
2111                date_picker()
2112                    .initial_selected_date(CalendarDate::new(2026, 7, 18).unwrap())
2113                    .initial_displayed_month(YearMonth::new(2026, 7).unwrap())
2114                    .year_range(2026..=2026)
2115                    .is_selectable_date(|date| date.day() % 2 == 0)
2116                    .is_selectable_year(|year| year == 2026);
2117            });
2118        });
2119    }
2120
2121    #[test]
2122    fn internal_calendar_initializes_predicates_without_a_slot_borrow_conflict() {
2123        crate::test_utils::run_isolated_test(
2124            "date_picker::tests::internal_calendar_initializes_predicates_without_a_slot_borrow_conflict",
2125            || {
2126                let mut session = start_layout_test_session(
2127                    layout_test(|| {
2128                        internal_predicate_fixture();
2129                    })
2130                    .viewport_px(400, 600),
2131                );
2132                let snapshot = pump_layout_test_session(&mut session, 0);
2133                snapshot.node("date-picker").size(360, 512);
2134                snapshot.node("date-grid").size(336, 288);
2135            },
2136        );
2137    }
2138
2139    #[test]
2140    fn calendar_uses_48dp_targets_and_selects_the_date_under_the_pointer() {
2141        let runtime = tessera_ui::RuntimeSession::new();
2142        let _scope = runtime.enter();
2143        tessera_ui::dp::set_scale_factor(1.0);
2144        let observed = Probe::default();
2145        let probe = observed.clone();
2146        let mut session = start_layout_test_session(
2147            layout_test(move || {
2148                calendar_fixture(probe.clone());
2149            })
2150            .viewport_px(400, 600),
2151        );
2152        let snapshot = pump_layout_test_session(&mut session, 0);
2153        snapshot.node("date-picker").size(360, 512);
2154        snapshot.node("date-grid").size(336, 288);
2155        let bounds = snapshot.node("date-grid").bounds();
2156        let cursor = Some(PxPosition::from_f32_xy(
2157            bounds.x.to_f32() + 4.5 * 48.0,
2158            bounds.y.to_f32() + 2.5 * 48.0,
2159        ));
2160        let day = session
2161            .inspect_components()
2162            .labeled(&super::I18N.format(
2163                "md3-full-date",
2164                [
2165                    ("month", "July".into()),
2166                    ("day", 16.into()),
2167                    ("year", "2026".into()),
2168                ],
2169            ))
2170            .bounds()
2171            .unwrap();
2172        let point = cursor.unwrap();
2173        assert!(
2174            point.x >= day.x
2175                && point.x < day.x + day.width
2176                && point.y >= day.y
2177                && point.y < day.y + day.height,
2178            "grid-derived pointer {point:?} must land in July 16 target {day:?}"
2179        );
2180        for (index, content) in [
2181            CursorEventContent::Pressed(PressKeyEventType::Left),
2182            CursorEventContent::Released(PressKeyEventType::Left),
2183        ]
2184        .into_iter()
2185        .enumerate()
2186        {
2187            pump_layout_test_session_with_input(
2188                &mut session,
2189                (index as u64 + 1) * 16_666_667,
2190                vec![PointerChange::new(
2191                    tessera_ui::current_frame_time().unwrap(),
2192                    1,
2193                    content,
2194                    GestureState::TapCandidate,
2195                )],
2196                cursor,
2197            );
2198        }
2199        assert_eq!(
2200            observed
2201                .state
2202                .get()
2203                .unwrap()
2204                .with(DatePickerState::selected_date),
2205            CalendarDate::new(2026, 7, 16)
2206        );
2207    }
2208    #[test]
2209    fn selecting_a_year_returns_to_the_calendar_without_selecting_a_date() {
2210        let runtime = tessera_ui::RuntimeSession::new();
2211        let _scope = runtime.enter();
2212        tessera_ui::dp::set_scale_factor(1.0);
2213        let observed = Probe::default();
2214        let probe = observed.clone();
2215        let mut session = start_layout_test_session(
2216            layout_test(move || {
2217                calendar_fixture(probe.clone());
2218            })
2219            .viewport_px(400, 600),
2220        );
2221        pump_layout_test_session(&mut session, 0);
2222        for (step, (x, y)) in [(100.0, 148.0), (68.0, 248.0)].into_iter().enumerate() {
2223            for (index, content) in [
2224                CursorEventContent::Pressed(PressKeyEventType::Left),
2225                CursorEventContent::Released(PressKeyEventType::Left),
2226            ]
2227            .into_iter()
2228            .enumerate()
2229            {
2230                let _ = pump_layout_test_session_with_input(
2231                    &mut session,
2232                    ((step * 3 + index + 1) as u64) * 16_666_667,
2233                    vec![PointerChange::new(
2234                        tessera_ui::current_frame_time().unwrap(),
2235                        1,
2236                        content,
2237                        GestureState::TapCandidate,
2238                    )],
2239                    Some(PxPosition::from_f32_xy(x, y)),
2240                );
2241                if step == 0 && index == 1 {
2242                    let snapshot = pump_layout_test_session(&mut session, 50_000_001);
2243                    snapshot.node("date-years").size(336, 288);
2244                }
2245            }
2246        }
2247        let state = observed.state.get().unwrap();
2248        assert_eq!(
2249            state.with(DatePickerState::displayed_month),
2250            YearMonth::new(2023, 7).unwrap()
2251        );
2252        assert_eq!(state.with(DatePickerState::selected_date), None);
2253    }
2254    #[test]
2255    fn narrow_calendars_preserve_seven_columns() {
2256        let runtime = tessera_ui::RuntimeSession::new();
2257        let _scope = runtime.enter();
2258        tessera_ui::dp::set_scale_factor(1.0);
2259        let observed = Probe::default();
2260        let probe = observed.clone();
2261        let mut session = start_layout_test_session(
2262            layout_test(move || {
2263                calendar_fixture(probe.clone());
2264            })
2265            .viewport_px(320, 600),
2266        );
2267        let snapshot = pump_layout_test_session(&mut session, 0);
2268        snapshot.node("date-grid").size(296, 288);
2269        let bounds = snapshot.node("date-grid").bounds();
2270        let cursor = Some(PxPosition::from_f32_xy(
2271            bounds.x.to_f32() + bounds.width.to_f32() * 4.5 / 7.0,
2272            bounds.y.to_f32() + 2.5 * 48.0,
2273        ));
2274        let day = session
2275            .inspect_components()
2276            .labeled(&super::I18N.format(
2277                "md3-full-date",
2278                [
2279                    ("month", "July".into()),
2280                    ("day", 16.into()),
2281                    ("year", "2026".into()),
2282                ],
2283            ))
2284            .bounds()
2285            .unwrap();
2286        let point = cursor.unwrap();
2287        assert!(
2288            point.x >= day.x
2289                && point.x < day.x + day.width
2290                && point.y >= day.y
2291                && point.y < day.y + day.height,
2292            "grid-derived pointer {point:?} must land in July 16 target {day:?}"
2293        );
2294        for (index, content) in [
2295            CursorEventContent::Pressed(PressKeyEventType::Left),
2296            CursorEventContent::Released(PressKeyEventType::Left),
2297        ]
2298        .into_iter()
2299        .enumerate()
2300        {
2301            pump_layout_test_session_with_input(
2302                &mut session,
2303                (index as u64 + 1) * 16_666_667,
2304                vec![PointerChange::new(
2305                    tessera_ui::current_frame_time().unwrap(),
2306                    1,
2307                    content,
2308                    GestureState::TapCandidate,
2309                )],
2310                cursor,
2311            );
2312        }
2313        assert_eq!(
2314            observed
2315                .state
2316                .get()
2317                .unwrap()
2318                .with(DatePickerState::selected_date),
2319            CalendarDate::new(2026, 7, 16)
2320        );
2321    }
2322    #[test]
2323    fn edit_button_switches_calendar_to_input_mode() {
2324        let runtime = tessera_ui::RuntimeSession::new();
2325        let _scope = runtime.enter();
2326        tessera_ui::dp::set_scale_factor(1.0);
2327        let observed = Probe::default();
2328        let probe = observed.clone();
2329        let mut session = start_layout_test_session(
2330            layout_test(move || {
2331                calendar_fixture(probe.clone());
2332            })
2333            .viewport_px(400, 600),
2334        );
2335        pump_layout_test_session(&mut session, 0);
2336        for (index, content) in [
2337            CursorEventContent::Pressed(PressKeyEventType::Left),
2338            CursorEventContent::Released(PressKeyEventType::Left),
2339        ]
2340        .into_iter()
2341        .enumerate()
2342        {
2343            pump_layout_test_session_with_input(
2344                &mut session,
2345                (index as u64 + 1) * 16_666_667,
2346                vec![PointerChange::new(
2347                    tessera_ui::current_frame_time().unwrap(),
2348                    1,
2349                    content,
2350                    GestureState::TapCandidate,
2351                )],
2352                Some(PxPosition::from_f32_xy(328.0, 84.0)),
2353            );
2354        }
2355        pump_layout_test_session(&mut session, 50_000_001);
2356        assert_eq!(
2357            observed
2358                .state
2359                .get()
2360                .unwrap()
2361                .with(DatePickerState::display_mode),
2362            super::DatePickerDisplayMode::Input
2363        );
2364    }
2365}
2366
2367#[cfg(all(test, not(target_family = "wasm")))]
2368#[path = "date_picker/focus_tests.rs"]
2369mod focus_tests;