1use std::ops::RangeInclusive;
8
9use tessera_ui::{
10 AxisConstraint, CallbackWith, Dp, FocusProperties, FocusRequester, FocusScopeNode, Modifier,
11 RenderSlotWith, State, layout::layout, modifier::FocusModifierExt as _, remember, tessera,
12 use_context,
13};
14
15use crate::{
16 alignment::{Alignment, CrossAxisAlignment},
17 column::column,
18 divider::horizontal_divider,
19 icon_button::icon_button,
20 lazy_list::{LazyListController, lazy_column},
21 material_icons::filled,
22 modifier::{ModifierExt as _, Padding, SemanticsArgs},
23 res::I18N,
24 row::row,
25 scrollable::ScrollBarBehavior,
26 spacer::spacer,
27 surface::surface,
28 text::text,
29 text_field::text_field,
30 text_input::TextInputController,
31 theme::MaterialTheme,
32};
33
34use super::{
35 CalendarDate, DATE_CELL_SIZE, DATE_COLUMNS, DATE_PICKER_MAX_WIDTH, DateGridLayout,
36 DatePickerDefaults, DatePickerDisplayMode, DatePickerState, Weekday, YearMonth,
37 build_month_grid, calendar_day, format_date_input, format_month_short_name, format_month_year,
38 navigation::{CalendarFocus, CalendarKeyboard, CalendarOwner},
39 parse_date_input, weekday_narrow_label, weekday_sequence,
40};
41
42const MONTH_HEIGHT: Dp = Dp(336.0);
43
44#[cfg(test)]
45#[path = "range_tests.rs"]
46mod tests;
47
48pub struct DateRangePickerState {
54 calendar: DatePickerState,
55 end: Option<CalendarDate>,
56 selection_revision: u64,
57 displayed_revision: u64,
58}
59
60impl DateRangePickerState {
61 pub fn new(
63 start: Option<CalendarDate>,
64 end: Option<CalendarDate>,
65 displayed_month: Option<YearMonth>,
66 year_range: RangeInclusive<i32>,
67 is_selectable_date: Option<CallbackWith<CalendarDate, bool>>,
68 is_selectable_year: Option<CallbackWith<i32, bool>>,
69 display_mode: DatePickerDisplayMode,
70 ) -> Self {
71 let calendar = DatePickerState::new(
72 start,
73 displayed_month,
74 year_range,
75 is_selectable_date,
76 is_selectable_year,
77 display_mode,
78 );
79 let mut state = Self {
80 calendar,
81 end: None,
82 selection_revision: 0,
83 displayed_revision: 0,
84 };
85 if let Some(end) = end {
86 state.set_selection(state.selected_start_date(), Some(end));
87 }
88 state
89 }
90
91 pub fn selected_start_date(&self) -> Option<CalendarDate> {
93 self.calendar.selected_date()
94 }
95
96 pub fn selected_end_date(&self) -> Option<CalendarDate> {
98 self.end
99 }
100
101 pub fn is_complete(&self) -> bool {
103 self.selected_start_date().is_some() && self.end.is_some()
104 }
105
106 pub fn set_selection(
112 &mut self,
113 start: Option<CalendarDate>,
114 end: Option<CalendarDate>,
115 ) -> bool {
116 if end.is_some_and(|end| start.is_none_or(|start| date_key(end) < date_key(start)))
117 || start.is_some_and(|date| !self.is_date_selectable(date))
118 || end.is_some_and(|date| !self.is_date_selectable(date))
119 {
120 return false;
121 }
122 self.calendar.selected_date = start;
123 self.end = end;
124 self.selection_revision = self.selection_revision.wrapping_add(1);
125 true
126 }
127
128 pub fn select_date(&mut self, date: CalendarDate) -> bool {
131 if !self.is_date_selectable(date) {
132 return false;
133 }
134 if self.end.is_some()
135 || self
136 .selected_start_date()
137 .is_none_or(|start| date_key(date) < date_key(start))
138 {
139 self.set_selection(Some(date), None)
140 } else {
141 self.set_selection(self.selected_start_date(), Some(date))
142 }
143 }
144
145 pub fn clear_selection(&mut self) {
147 self.set_selection(None, None);
148 }
149
150 pub fn is_date_selectable(&self, date: CalendarDate) -> bool {
152 super::is_date_selectable(
153 date,
154 self.year_range(),
155 self.calendar.is_selectable_date.as_ref(),
156 self.calendar.is_selectable_year.as_ref(),
157 )
158 }
159
160 pub fn year_range(&self) -> &RangeInclusive<i32> {
162 self.calendar.year_range()
163 }
164
165 pub fn set_year_range(&mut self, range: RangeInclusive<i32>) {
168 self.calendar.set_year_range(range);
169 self.clear_unselectable_end();
170 }
171
172 pub fn set_is_selectable_date(&mut self, predicate: Option<CallbackWith<CalendarDate, bool>>) {
174 self.calendar.set_is_selectable_date(predicate);
175 self.clear_unselectable_end();
176 }
177
178 pub fn set_is_selectable_year(&mut self, predicate: Option<CallbackWith<i32, bool>>) {
180 self.calendar.set_is_selectable_year(predicate);
181 self.clear_unselectable_end();
182 }
183
184 pub fn displayed_month(&self) -> YearMonth {
186 self.calendar.displayed_month()
187 }
188
189 pub fn set_displayed_month(&mut self, month: YearMonth) {
191 self.calendar.set_displayed_month(month);
192 self.displayed_revision = self.displayed_revision.wrapping_add(1);
193 }
194
195 pub fn display_mode(&self) -> DatePickerDisplayMode {
197 self.calendar.display_mode()
198 }
199
200 pub fn set_display_mode(&mut self, mode: DatePickerDisplayMode) {
203 if self.display_mode() != mode {
204 if let Some(start) = self.selected_start_date() {
205 self.set_displayed_month(
206 YearMonth::new(start.year(), start.month()).expect("valid month"),
207 );
208 }
209 self.calendar.set_display_mode(mode);
210 }
211 }
212
213 pub fn toggle_display_mode(&mut self) {
215 self.set_display_mode(if self.display_mode() == DatePickerDisplayMode::Picker {
216 DatePickerDisplayMode::Input
217 } else {
218 DatePickerDisplayMode::Picker
219 });
220 }
221
222 fn clear_unselectable_end(&mut self) {
223 if self.selected_start_date().is_none()
224 || self.end.is_some_and(|date| !self.is_date_selectable(date))
225 {
226 self.end = None;
227 }
228 }
229}
230
231impl Default for DateRangePickerState {
232 fn default() -> Self {
233 Self::new(
234 None,
235 None,
236 None,
237 DatePickerDefaults::YEAR_RANGE,
238 None,
239 None,
240 DatePickerDisplayMode::Picker,
241 )
242 }
243}
244
245fn date_key(date: CalendarDate) -> (i32, u8, u8) {
246 (date.year(), date.month(), date.day())
247}
248
249#[tessera]
294pub fn date_range_picker(
295 state: Option<State<DateRangePickerState>>,
296 modifier: Option<Modifier>,
297 first_day_of_week: Option<Weekday>,
298 today: Option<CalendarDate>,
299 calendar_height: Option<Dp>,
300 focus_requester: Option<FocusRequester>,
301 show_mode_toggle: Option<bool>,
302 #[prop(into)] title: Option<String>,
303 #[prop(into)] headline: Option<String>,
304) {
305 let today = today.unwrap_or_else(CalendarDate::today);
306 let state = if let Some(state) = state {
307 state
308 } else {
309 remember(|| {
310 DateRangePickerState::new(
311 None,
312 None,
313 Some(super::fallback_displayed_month_at(
314 &DatePickerDefaults::YEAR_RANGE,
315 today,
316 )),
317 DatePickerDefaults::YEAR_RANGE,
318 None,
319 None,
320 DatePickerDisplayMode::Picker,
321 )
322 })
323 };
324 let theme = use_context::<MaterialTheme>()
325 .expect("MaterialTheme must be provided")
326 .get();
327 let input = state.with(|state| state.display_mode() == DatePickerDisplayMode::Input);
328 let title = title.unwrap_or_else(|| {
329 I18N.text(if input {
330 "md3-select-date"
331 } else {
332 "md3-select-dates"
333 })
334 });
335 let headline = headline.unwrap_or_else(|| {
336 if input {
337 return I18N.text("md3-enter-dates");
338 }
339 state.with(|state| {
340 let cross_year = state
341 .selected_start_date()
342 .zip(state.selected_end_date())
343 .is_some_and(|(start, end)| start.year() != end.year());
344 let format = move |date: CalendarDate| {
345 let value = I18N.format(
346 "md3-month-day",
347 [
348 ("month", format_month_short_name(date.month()).into()),
349 ("day", date.day().into()),
350 ],
351 );
352 if cross_year {
353 I18N.format(
354 "md3-date-with-year",
355 [
356 ("date", value.into()),
357 ("year", (date.year()).to_string().into()),
358 ],
359 )
360 } else {
361 value
362 }
363 };
364 I18N.format(
365 "md3-date-range",
366 [
367 (
368 "start",
369 state
370 .selected_start_date()
371 .map(format)
372 .unwrap_or_else(|| I18N.text("md3-start-date"))
373 .into(),
374 ),
375 (
376 "end",
377 state
378 .selected_end_date()
379 .map(format)
380 .unwrap_or_else(|| I18N.text("md3-end-date"))
381 .into(),
382 ),
383 ],
384 )
385 })
386 });
387 column()
388 .modifier(modifier.unwrap_or_default().width(DATE_PICKER_MAX_WIDTH))
389 .children(move || {
390 let title = title.clone();
391 let headline = headline.clone();
392 if input {
393 column()
394 .modifier(
395 Modifier::new()
396 .fill_max_width()
397 .height(Dp(120.0))
398 .padding(Padding::new(Dp(24.0), Dp(16.0), Dp(12.0), Dp(12.0))),
399 )
400 .children(move || {
401 text(title.clone())
402 .style(theme.typography.label_large)
403 .color(theme.color_scheme.on_surface_variant);
404 spacer().modifier(Modifier::new().height(Dp(24.0)));
405 let headline = headline.clone();
406 row()
407 .modifier(Modifier::new().fill_max_width().height(Dp(48.0)))
408 .cross_axis_alignment(CrossAxisAlignment::Center)
409 .children(move || {
410 text(headline.clone())
411 .style(theme.typography.headline_large)
412 .color(theme.color_scheme.on_surface)
413 .modifier(Modifier::new().weight(1.0));
414 if show_mode_toggle.unwrap_or(true) {
415 icon_button()
416 .standard()
417 .icon(filled::CALENDAR_TODAY_SVG)
418 .accessibility_label(
419 I18N.text("md3-switch-to-range-calendar"),
420 )
421 .on_click(move || {
422 state
423 .with_mut(DateRangePickerState::toggle_display_mode)
424 });
425 }
426 });
427 });
428 } else {
429 row()
430 .modifier(
431 Modifier::new()
432 .fill_max_width()
433 .constrain(None, Some(AxisConstraint::new(Dp(68.0).to_px(), None)))
434 .padding(Padding::new(Dp(64.0), Dp::ZERO, Dp(12.0), Dp(12.0))),
435 )
436 .cross_axis_alignment(CrossAxisAlignment::End)
437 .children(move || {
438 let title = title.clone();
439 let headline = headline.clone();
440 column()
441 .modifier(Modifier::new().weight(1.0))
442 .children(move || {
443 text(title.clone())
444 .style(theme.typography.label_large)
445 .color(theme.color_scheme.on_surface_variant);
446 spacer().modifier(Modifier::new().height(Dp(4.0)));
447 text(headline.clone())
448 .style(theme.typography.title_large)
449 .color(theme.color_scheme.on_surface_variant);
450 });
451 if show_mode_toggle.unwrap_or(true) {
452 icon_button()
453 .standard()
454 .icon(if input {
455 filled::CALENDAR_TODAY_SVG
456 } else {
457 filled::EDIT_SVG
458 })
459 .accessibility_label(if input {
460 I18N.text("md3-switch-to-range-calendar")
461 } else {
462 I18N.text("md3-switch-to-date-range-input")
463 })
464 .on_click(move || {
465 state.with_mut(DateRangePickerState::toggle_display_mode)
466 });
467 }
468 });
469 }
470 horizontal_divider().color(theme.color_scheme.outline_variant);
471 if input {
472 range_input(state);
473 } else {
474 range_calendar(
475 state,
476 first_day_of_week.unwrap_or_default(),
477 calendar_height.unwrap_or(Dp(360.0)),
478 today,
479 )
480 .focus_requester_optional(focus_requester);
481 }
482 });
483}
484
485#[tessera]
486fn range_calendar(
487 state: State<DateRangePickerState>,
488 first_day: Weekday,
489 height: Dp,
490 today: CalendarDate,
491 focus_requester: Option<FocusRequester>,
492) {
493 let theme = use_context::<MaterialTheme>().expect("theme").get();
494 let controller = remember(LazyListController::new);
495 let focus_scope = remember(FocusScopeNode::new).get();
496 let viewport_focus = remember(FocusRequester::new).get();
497 let owner = CalendarOwner::Range(state);
498 let initial = owner.initial_date(today);
499 let focus = remember(move || CalendarFocus::new(initial, focus_requester));
500 let keyboard = CalendarKeyboard {
501 focus,
502 owner,
503 first_day,
504 };
505 let current_date = remember(|| today);
506 if current_date.get() != today {
507 current_date.set(today);
508 }
509 let first_year = state.with(|state| *state.year_range().start());
510 let months = state
511 .with(|state| ((*state.year_range().end() as i64 - first_year as i64 + 1) * 12) as usize);
512 let displayed = state.with(DateRangePickerState::displayed_month);
513 let revision = state.with(|state| state.displayed_revision);
514 let month_height = MONTH_HEIGHT.to_px();
515 let previous = remember(|| None);
516 let scroll = controller.with(|controller| controller.scroll_controller().child_position());
517 if previous.get() != Some((first_year, displayed, revision, month_height)) {
518 let index = (displayed.year() - first_year) * 12 + displayed.month() as i32 - 1;
519 controller.with_mut(|controller| {
520 controller.scroll_to(Dp::ZERO, Dp(MONTH_HEIGHT.0 * index as f64))
521 });
522 previous.set(Some((first_year, displayed, revision, month_height)));
523 } else {
524 let index = (-scroll.y / MONTH_HEIGHT.to_px().to_f32().max(1.0)).max(0.0) as i32;
525 let month = YearMonth::new(first_year, 1)
526 .expect("January")
527 .add_months(index);
528 if month != displayed {
529 state.with_mut(|state| state.calendar.set_displayed_month(month));
530 previous.set(Some((first_year, month, revision, month_height)));
531 }
532 }
533 keyboard.sync(today);
534 let modifier = Modifier::new()
535 .fill_max_width()
536 .padding_symmetric(Dp(12.0), Dp::ZERO)
537 .focus_restorer_with(focus_scope, Some(viewport_focus));
538 column().modifier(modifier).children(move || {
539 layout()
540 .policy(DateGridLayout {
541 columns: DATE_COLUMNS,
542 row_height: DATE_CELL_SIZE,
543 })
544 .child(move || {
545 for day in weekday_sequence(first_day) {
546 surface()
547 .style(tessera_ui::Color::TRANSPARENT.into())
548 .modifier(Modifier::new().size(DATE_CELL_SIZE, DATE_CELL_SIZE))
549 .content_alignment(Alignment::Center)
550 .child(move || {
551 text(weekday_narrow_label(day))
552 .style(theme.typography.body_large)
553 .color(theme.color_scheme.on_surface);
554 });
555 }
556 });
557 lazy_column()
558 .modifier(
559 keyboard.viewport_modifier(
560 Modifier::new()
561 .fill_max_width()
562 .height(height)
563 .focusable()
564 .focus_requester(viewport_focus)
565 .focus_properties(FocusProperties::new().skip_traversal(true))
566 .semantics(SemanticsArgs {
567 role: Some(tessera_ui::accesskit::Role::Group),
568 label: Some(I18N.text("md3-date-range-calendar").into()),
569 merge_descendants: true,
570 ..Default::default()
571 }),
572 ),
573 )
574 .estimated_item_size(MONTH_HEIGHT)
575 .max_viewport_main(height.to_px())
576 .scrollbar_behavior(ScrollBarBehavior::Hidden)
577 .controller(controller)
578 .items_with_key_shared(
579 months,
580 move |index| state.with(|state| (*state.year_range().start(), index)),
581 RenderSlotWith::new(move |index| {
582 let first_year = state.with(|state| *state.year_range().start());
583 let month = YearMonth::new(first_year, 1)
584 .expect("January")
585 .add_months(index as i32);
586 range_month(state, month, first_day, keyboard, current_date.get());
587 }),
588 );
589 });
590}
591
592#[tessera]
593fn range_month(
594 state: State<DateRangePickerState>,
595 month: YearMonth,
596 first_day: Weekday,
597 keyboard: CalendarKeyboard,
598 today: CalendarDate,
599) {
600 let theme = use_context::<MaterialTheme>().expect("theme").get();
601 let grid: [Option<CalendarDate>; DATE_COLUMNS * super::DATE_ROWS] =
602 build_month_grid(month, first_day)
603 .try_into()
604 .expect("six calendar rows");
605 let start = state.with(DateRangePickerState::selected_start_date);
606 let end = state.with(DateRangePickerState::selected_end_date);
607 let on_select = CallbackWith::new(move |date| {
608 state.with_mut(|state| state.select_date(date));
609 });
610 column()
611 .modifier(Modifier::new().fill_max_width().semantics(SemanticsArgs {
612 label: Some(format_month_year(month)),
613 role: Some(tessera_ui::accesskit::Role::Group),
614 merge_descendants: true,
615 ..Default::default()
616 }))
617 .children(move || {
618 column()
619 .modifier(Modifier::new().padding(Padding::new(
620 Dp(24.0),
621 Dp(20.0),
622 Dp::ZERO,
623 Dp(8.0),
624 )))
625 .children(move || {
626 text(format_month_year(month))
627 .style(theme.typography.title_small)
628 .color(theme.color_scheme.on_surface_variant);
629 });
630 layout()
631 .policy(DateGridLayout {
632 columns: DATE_COLUMNS,
633 row_height: DATE_CELL_SIZE,
634 })
635 .child(move || {
636 for date in grid.iter().copied() {
637 let in_range =
638 date.zip(start)
639 .zip(end)
640 .is_some_and(|((date, start), end)| {
641 date_key(date) >= date_key(start)
642 && date_key(date) <= date_key(end)
643 });
644 let selected = date.is_some() && (date == start || date == end);
645 let enabled = date
646 .is_some_and(|date| state.with(|state| state.is_date_selectable(date)));
647 calendar_day(on_select, today)
648 .cell_optional(date)
649 .selected(selected)
650 .enabled(enabled)
651 .keyboard(keyboard)
652 .in_range(in_range)
653 .range_band_optional(in_range.then_some(super::CalendarRangeBand {
654 start: date == start,
655 end: date == end,
656 }))
657 .description_optional(date.map(|_| {
658 if date == start {
659 I18N.text("md3-start-date")
660 } else if date == end {
661 I18N.text("md3-end-date")
662 } else if in_range {
663 I18N.text("md3-in-selected-range")
664 } else {
665 I18N.text("md3-not-selected")
666 }
667 .to_owned()
668 }));
669 }
670 });
671 });
672}
673
674#[tessera]
675fn range_input(state: State<DateRangePickerState>) {
676 let selected = selection(state);
677 let controllers = [
678 remember(|| input_controller(selected.1)),
679 remember(|| input_controller(selected.2)),
680 ];
681 let draft = remember(|| {
682 [
683 selected.1.map(format_date_input).unwrap_or_default(),
684 selected.2.map(format_date_input).unwrap_or_default(),
685 ]
686 });
687 let last_selected = remember(|| selected);
688 if last_selected.get() != selected {
689 let values = [
690 selected.1.map(format_date_input).unwrap_or_default(),
691 selected.2.map(format_date_input).unwrap_or_default(),
692 ];
693 for (controller, value) in controllers.into_iter().zip(&values) {
694 if controller.with(|controller| controller.text()) != *value {
695 controller.with_mut(|controller| controller.set_text(value));
696 }
697 }
698 draft.set(values);
699 last_selected.set(selected);
700 }
701 row()
702 .modifier(Modifier::new().fill_max_width().padding_all(Dp(24.0)))
703 .cross_axis_alignment(CrossAxisAlignment::Start)
704 .children(move || {
705 column()
706 .modifier(Modifier::new().weight(1.0))
707 .children(move || {
708 range_input_field(state, true, controllers[0], draft, last_selected);
709 });
710 spacer().modifier(Modifier::new().width(Dp(16.0)));
711 column()
712 .modifier(Modifier::new().weight(1.0))
713 .children(move || {
714 range_input_field(state, false, controllers[1], draft, last_selected);
715 });
716 });
717}
718
719fn selection(
720 state: State<DateRangePickerState>,
721) -> (u64, Option<CalendarDate>, Option<CalendarDate>) {
722 state.with(|state| {
723 (
724 state.selection_revision,
725 state.selected_start_date(),
726 state.selected_end_date(),
727 )
728 })
729}
730
731fn input_controller(date: Option<CalendarDate>) -> TextInputController {
732 let mut controller = TextInputController::new(Dp(16.0), Some(Dp(24.0)));
733 controller.set_text(&date.map(format_date_input).unwrap_or_default());
734 controller
735}
736
737fn apply_draft(state: &mut DateRangePickerState, values: &[String; 2]) {
738 let start = parse_date_input(&values[0]).filter(|date| state.is_date_selectable(*date));
739 let end = parse_date_input(&values[1]).filter(|date| {
740 state.is_date_selectable(*date)
741 && start.is_some_and(|start| date_key(start) <= date_key(*date))
742 });
743 state.set_selection(start, end);
744}
745
746#[tessera]
747fn range_input_field(
748 state: State<DateRangePickerState>,
749 start: bool,
750 controller: State<TextInputController>,
751 draft: State<[String; 2]>,
752 last_selected: State<(u64, Option<CalendarDate>, Option<CalendarDate>)>,
753) {
754 let value = controller.with(|controller| controller.text());
755 let error = state.with(|state| input_error(&value, start, state));
756 let theme = use_context::<MaterialTheme>().expect("theme").get();
757 text_field()
758 .outlined()
759 .modifier(Modifier::new().fill_max_width().height(Dp(56.0)))
760 .controller(controller)
761 .background_color(theme.color_scheme.surface_container_high)
762 .focus_background_color(theme.color_scheme.surface_container_high)
763 .label(if start {
764 I18N.text("md3-start-date")
765 } else {
766 I18N.text("md3-end-date")
767 })
768 .placeholder("MM/DD/YYYY")
769 .is_error(error.is_some())
770 .on_change(move |value| {
771 if value.len() > 10
772 || !value
773 .bytes()
774 .all(|byte| byte.is_ascii_digit() || byte == b'/')
775 {
776 return draft.with(|draft| draft[usize::from(!start)].clone());
777 }
778 draft.with_mut(|draft| draft[usize::from(!start)] = value.clone());
779 state.with_mut(|state| {
780 draft.with(|draft| apply_draft(state, draft));
781 });
782 last_selected.set(selection(state));
783 value
784 });
785 if let Some(error) = error {
786 text(error)
787 .style(theme.typography.body_small)
788 .color(theme.color_scheme.error);
789 }
790}
791
792fn input_error(value: &str, start: bool, state: &DateRangePickerState) -> Option<String> {
793 if value.is_empty() {
794 return None;
795 }
796 let Some(date) = parse_date_input(value) else {
797 return Some(I18N.text("md3-enter-a-date-as-mm-dd-yyyy"));
798 };
799 if !state.is_date_selectable(date) {
800 return Some(I18N.text("md3-date-is-not-allowed"));
801 }
802 if !start && state.selected_start_date().is_none() {
803 return Some(I18N.text("md3-enter-a-start-date-first"));
804 }
805 if !start
806 && state
807 .selected_start_date()
808 .is_some_and(|start| date_key(date) < date_key(start))
809 {
810 return Some(I18N.text("md3-end-date-must-be-on-or-after-start-date"));
811 }
812 None
813}