1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum Weekday {
60 #[default]
62 Monday,
63 Tuesday,
65 Wednesday,
67 Thursday,
69 Friday,
71 Saturday,
73 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct CalendarDate {
106 year: i32,
107 month: u8,
108 day: u8,
109}
110
111impl CalendarDate {
112 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 pub fn year(&self) -> i32 {
126 self.year
127 }
128
129 pub fn month(&self) -> u8 {
131 self.month
132 }
133
134 pub fn day(&self) -> u8 {
136 self.day
137 }
138
139 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct YearMonth {
158 year: i32,
159 month: u8,
160}
161
162impl YearMonth {
163 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 pub fn year(&self) -> i32 {
173 self.year
174 }
175
176 pub fn month(&self) -> u8 {
178 self.month
179 }
180
181 pub fn to_date(&self, day: u8) -> Option<CalendarDate> {
183 CalendarDate::new(self.year, self.month, day)
184 }
185
186 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum DatePickerDisplayMode {
202 #[default]
204 Picker,
205 Input,
207}
208
209#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
211pub enum DatePickerView {
212 #[default]
214 Calendar,
215 Year,
217}
218
219pub struct DatePickerDefaults;
221
222impl DatePickerDefaults {
223 pub const YEAR_RANGE: RangeInclusive<i32> = 1900..=2100;
225}
226
227pub 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 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 pub fn selected_date(&self) -> Option<CalendarDate> {
287 self.selected_date
288 }
289
290 pub fn displayed_month(&self) -> YearMonth {
292 self.displayed_month
293 }
294
295 pub fn display_mode(&self) -> DatePickerDisplayMode {
297 self.display_mode
298 }
299
300 pub fn year_range(&self) -> &RangeInclusive<i32> {
302 &self.year_range
303 }
304
305 pub fn is_selectable_date(&self) -> Option<CallbackWith<CalendarDate, bool>> {
310 self.is_selectable_date
311 }
312
313 pub fn is_selectable_year(&self) -> Option<CallbackWith<i32, bool>> {
317 self.is_selectable_year
318 }
319
320 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 pub fn clear_selected_date(&mut self) {
339 self.selected_date = None;
340 }
341
342 pub fn set_displayed_month(&mut self, month: YearMonth) {
344 self.displayed_month = clamp_month_to_range(month, &self.year_range);
345 }
346
347 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 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 pub fn set_display_mode(&mut self, mode: DatePickerDisplayMode) {
364 self.display_mode = mode;
365 }
366
367 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 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 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 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#[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 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#[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;