Skip to main content

tessera_components/date_picker/
docked.rs

1//! Compact calendars for date fields and anchored date selection.
2//!
3//! ## Usage
4//!
5//! Place a calendar below a date field in an anchored popup or form.
6
7use tessera_ui::{
8    Callback, CallbackWith, Dp, Modifier, Px, PxPosition, State, accesskit::Role, layout::layout,
9    modifier::FocusModifierExt as _, remember, tessera, use_context,
10};
11
12use crate::{
13    alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
14    boxed::boxed,
15    button::button,
16    column::column,
17    divider::horizontal_divider,
18    icon::icon,
19    icon_button::icon_button,
20    material_icons::filled,
21    modifier::{ModifierExt as _, SemanticsArgs},
22    res::I18N,
23    row::row,
24    scrollable::ScrollableController,
25    shape_def::{RoundedCorner, Shape},
26    spacer::spacer,
27    surface::surface,
28    text::text,
29    theme::{MaterialAlpha, MaterialTheme},
30};
31
32use super::{
33    CalendarDate, CalendarFocus, CalendarKeyboard, CalendarOwner, DATE_CELL_SIZE, DATE_COLUMNS,
34    DATE_PICKER_MAX_WIDTH, DateGridLayout, DatePickerState, Weekday, YearMonth, calendar_day,
35    civil_from_days, days_from_civil, format_month_name, format_month_short_name,
36    is_date_selectable, weekday_for_date, weekday_narrow_label, weekday_sequence,
37};
38
39/// The content displayed beneath a docked calendar's navigation header.
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41pub enum DockedDatePickerView {
42    /// The weekday headings and date grid.
43    #[default]
44    Calendar,
45    /// The list of months in the displayed year.
46    Month,
47    /// The list of years allowed by the date-picker state.
48    Year,
49}
50
51/// # docked_date_picker
52///
53/// Select a date with separate month and year menus beside a date-entry field.
54///
55/// ## Usage
56///
57/// Show an anchored calendar for dates in the near or distant past or future.
58///
59/// ## Parameters
60///
61/// - `modifier` — placement and optional constraints on the 360dp calendar.
62/// - `state` — selection, displayed month and selectable-date restrictions.
63/// - `view` — optional shared calendar/month/year view state; when omitted, the
64///   calendar manages its own view.
65/// - `first_day_of_week` — first weekday in the calendar, Monday by default.
66/// - `today` — date outlined as today; defaults to the system date.
67/// - `show_actions` — show Cancel and OK, enabled by default.
68/// - `on_date_change` — called after an allowed date is selected.
69/// - `on_cancel`, `on_confirm` — dismissal and confirmation actions; the owner
70///   controls the popup and any rollback of an unconfirmed selection.
71///
72/// ## Examples
73///
74/// ```
75/// use tessera_components::date_picker::{CalendarDate, DatePickerState};
76/// let mut state = DatePickerState::default();
77/// let selected = CalendarDate::new(2025, 8, 17).unwrap();
78/// assert!(state.set_selected_date(selected));
79/// assert_eq!(state.selected_date(), Some(selected));
80/// ```
81#[tessera]
82pub fn docked_date_picker(
83    modifier: Option<Modifier>,
84    state: Option<State<DatePickerState>>,
85    view: Option<State<DockedDatePickerView>>,
86    first_day_of_week: Option<Weekday>,
87    today: Option<CalendarDate>,
88    show_actions: Option<bool>,
89    on_date_change: Option<CallbackWith<CalendarDate>>,
90    focus_requester: Option<tessera_ui::FocusRequester>,
91    on_cancel: Option<Callback>,
92    on_confirm: Option<Callback>,
93) {
94    let internal = remember(DatePickerState::default);
95    let state = state.unwrap_or(internal);
96    let internal_view = remember(DockedDatePickerView::default);
97    let menu = view.unwrap_or(internal_view);
98    let first_day = first_day_of_week.unwrap_or_default();
99    let today = today.unwrap_or_else(CalendarDate::today);
100    let scheme = use_context::<MaterialTheme>().unwrap().get().color_scheme;
101    let change = on_date_change.unwrap_or_else(|| CallbackWith::new(|_| {}));
102    let corner = RoundedCorner::manual(Dp(16.0), 2.0);
103    surface()
104        .modifier(modifier.unwrap_or_default().width(DATE_PICKER_MAX_WIDTH))
105        .shape(Shape::RoundedRectangle {
106            top_left: corner,
107            top_right: corner,
108            bottom_right: corner,
109            bottom_left: corner,
110        })
111        .style(scheme.surface_container_high.into())
112        .accessibility_role(Role::Group)
113        .accessibility_label(I18N.text("md3-docked-calendar"))
114        .block_input(false)
115        .child(move || {
116            if menu.get() != DockedDatePickerView::Calendar {
117                column()
118                    .modifier(Modifier::new().fill_max_width())
119                    .children(move || {
120                        menu_header(state, menu);
121                        match menu.get() {
122                            DockedDatePickerView::Month => {
123                                month_menu(state, menu);
124                            }
125                            DockedDatePickerView::Year => {
126                                year_menu(state, menu);
127                            }
128                            DockedDatePickerView::Calendar => unreachable!(),
129                        }
130                        spacer().modifier(Modifier::new().height(Dp(28.0)));
131                    });
132            } else {
133                calendar_content(
134                    state,
135                    menu,
136                    first_day,
137                    today,
138                    change,
139                    show_actions.unwrap_or(true),
140                    on_cancel.unwrap_or_default(),
141                    on_confirm.unwrap_or_default(),
142                )
143                .focus_requester_optional(focus_requester);
144            }
145        });
146}
147
148#[tessera]
149fn calendar_content(
150    state: State<DatePickerState>,
151    menu: State<DockedDatePickerView>,
152    first_day: Weekday,
153    today: CalendarDate,
154    change: CallbackWith<CalendarDate>,
155    show_actions: bool,
156    on_cancel: Callback,
157    on_confirm: Callback,
158    focus_requester: Option<tessera_ui::FocusRequester>,
159) {
160    column()
161        .modifier(Modifier::new().fill_max_width().padding_all(Dp(12.0)))
162        .children(move || {
163            boxed()
164                .modifier(Modifier::new().fill_max_width().height(Dp(44.0)))
165                .children(move || {
166                    header(state, menu);
167                });
168            calendar(state, first_day, today, change).focus_requester_optional(focus_requester);
169            if show_actions {
170                spacer().modifier(Modifier::new().height(Dp(8.0)));
171                row()
172                    .modifier(Modifier::new().fill_max_width().height(Dp(48.0)))
173                    .main_axis_alignment(MainAxisAlignment::End)
174                    .cross_axis_alignment(CrossAxisAlignment::Center)
175                    .children(move || {
176                        button()
177                            .text()
178                            .accessibility_label(I18N.text("md3-cancel"))
179                            .on_click_shared(on_cancel)
180                            .child(move || {
181                                text(I18N.text("md3-cancel"));
182                            });
183                        spacer().modifier(Modifier::new().width(Dp(8.0)));
184                        button()
185                            .text()
186                            .accessibility_label(I18N.text("md3-ok"))
187                            .on_click_shared(on_confirm)
188                            .child(move || {
189                                text(I18N.text("md3-ok"));
190                            });
191                    });
192            }
193        });
194}
195
196#[tessera]
197fn menu_header(state: State<DatePickerState>, menu: State<DockedDatePickerView>) {
198    let current = state.with(DatePickerState::displayed_month);
199    let theme = use_context::<MaterialTheme>().unwrap().get();
200    boxed()
201        .modifier(Modifier::new().fill_max_width().height(Dp(64.0)))
202        .children(move || {
203            row()
204                .modifier(Modifier::new().fill_max_size())
205                .cross_axis_alignment(CrossAxisAlignment::Center)
206                .children(move || {
207                    for (view, label, description) in [
208                        (
209                            DockedDatePickerView::Month,
210                            format_month_short_name(current.month()),
211                            I18N.text("md3-choose-month"),
212                        ),
213                        (
214                            DockedDatePickerView::Year,
215                            current.year().to_string(),
216                            I18N.text("md3-choose-year"),
217                        ),
218                    ] {
219                        boxed()
220                            .alignment(Alignment::Center)
221                            .modifier(Modifier::new().weight(1.0).fill_max_height())
222                            .children(move || {
223                                if menu.get() == view {
224                                    menu_button(
225                                        label.clone(),
226                                        description.clone(),
227                                        if view == DockedDatePickerView::Month {
228                                            Dp(64.0)
229                                        } else {
230                                            Dp(72.0)
231                                        },
232                                        true,
233                                        Callback::new(move || {
234                                            menu.set(DockedDatePickerView::Calendar)
235                                        }),
236                                    );
237                                } else {
238                                    text(label.clone())
239                                        .style(theme.typography.label_large)
240                                        .color(
241                                            theme
242                                                .color_scheme
243                                                .on_surface
244                                                .with_alpha(MaterialAlpha::DISABLED_CONTENT),
245                                        );
246                                }
247                            });
248                    }
249                });
250            boxed()
251                .modifier(
252                    Modifier::new()
253                        .align(Alignment::BottomStart)
254                        .fill_max_width(),
255                )
256                .children(|| {
257                    horizontal_divider();
258                });
259        });
260}
261
262#[tessera]
263fn header(state: State<DatePickerState>, menu: State<DockedDatePickerView>) {
264    let month = state.with(DatePickerState::displayed_month);
265    let years = state.with(|state| state.year_range().clone());
266    row()
267        .modifier(Modifier::new().fill_max_width().height(Dp(48.0)))
268        .cross_axis_alignment(CrossAxisAlignment::Center)
269        .main_axis_alignment(MainAxisAlignment::SpaceBetween)
270        .children(move || {
271            arrow(
272                I18N.text("md3-previous-month").into(),
273                false,
274                month.month() > 1 || month.year() > *years.start(),
275                Callback::new(move || state.with_mut(DatePickerState::previous_month)),
276            );
277            menu_button(
278                format_month_short_name(month.month()),
279                I18N.text("md3-choose-month").into(),
280                Dp(64.0),
281                menu.get() == DockedDatePickerView::Month,
282                Callback::new(move || {
283                    menu.set(if menu.get() == DockedDatePickerView::Month {
284                        DockedDatePickerView::Calendar
285                    } else {
286                        DockedDatePickerView::Month
287                    })
288                }),
289            );
290            arrow(
291                I18N.text("md3-next-month").into(),
292                true,
293                month.month() < 12 || month.year() < *years.end(),
294                Callback::new(move || state.with_mut(DatePickerState::next_month)),
295            );
296            arrow(
297                I18N.text("md3-previous-year").into(),
298                false,
299                menu.get() != DockedDatePickerView::Month && month.year() > *years.start(),
300                Callback::new(move || {
301                    state.with_mut(|state| {
302                        let current = state.displayed_month();
303                        state.set_displayed_month(
304                            YearMonth::new(current.year().saturating_sub(1), current.month())
305                                .unwrap(),
306                        );
307                    })
308                }),
309            );
310            menu_button(
311                month.year().to_string(),
312                I18N.text("md3-choose-year").into(),
313                Dp(72.0),
314                menu.get() == DockedDatePickerView::Year,
315                Callback::new(move || {
316                    menu.set(if menu.get() == DockedDatePickerView::Year {
317                        DockedDatePickerView::Calendar
318                    } else {
319                        DockedDatePickerView::Year
320                    })
321                }),
322            );
323            arrow(
324                I18N.text("md3-next-year").into(),
325                true,
326                menu.get() != DockedDatePickerView::Month && month.year() < *years.end(),
327                Callback::new(move || {
328                    state.with_mut(|state| {
329                        let current = state.displayed_month();
330                        state.set_displayed_month(
331                            YearMonth::new(current.year().saturating_add(1), current.month())
332                                .unwrap(),
333                        );
334                    })
335                }),
336            );
337        });
338}
339
340#[tessera]
341fn arrow(label: String, next: bool, enabled: bool, action: Callback) {
342    boxed()
343        .alignment(Alignment::Center)
344        .modifier(Modifier::new().size(Dp(48.0), Dp(48.0)))
345        .children(move || {
346            icon_button()
347                .standard()
348                .icon(if next {
349                    filled::CHEVRON_RIGHT_SVG
350                } else {
351                    filled::CHEVRON_LEFT_SVG
352                })
353                .enabled(enabled)
354                .accessibility_label(label.clone())
355                .on_click_shared(action);
356        });
357}
358
359#[tessera]
360fn menu_button(label: String, description: String, width: Dp, expanded: bool, action: Callback) {
361    let theme = use_context::<MaterialTheme>().unwrap().get();
362    surface()
363        .modifier(
364            Modifier::new()
365                .width(width)
366                .height(Dp(48.0))
367                .semantics(SemanticsArgs {
368                    expanded: Some(expanded),
369                    ..Default::default()
370                }),
371        )
372        .shape(Shape::rounded_rectangle(Dp(8.0)))
373        .style(tessera_ui::Color::TRANSPARENT.into())
374        .content_alignment(Alignment::Center)
375        .on_click_shared(action)
376        .accessibility_label(description)
377        .child(move || {
378            let label = label.clone();
379            row()
380                .modifier(Modifier::new().padding_symmetric(Dp(8.0), Dp::ZERO))
381                .cross_axis_alignment(CrossAxisAlignment::Center)
382                .children(move || {
383                    text(label.clone())
384                        .style(theme.typography.label_large)
385                        .color(theme.color_scheme.on_surface);
386                    spacer().modifier(Modifier::new().width(Dp(4.0)));
387                    icon()
388                        .painter(filled::ARROW_DROP_DOWN_SVG)
389                        .size(Dp(18.0))
390                        .tint(theme.color_scheme.on_surface_variant);
391                });
392        });
393}
394
395fn cells(month: YearMonth, first: Weekday) -> [CalendarDate; 42] {
396    let date = month.to_date(1).unwrap();
397    let offset =
398        (weekday_for_date(date).index_from_monday() - first.index_from_monday()).rem_euclid(7);
399    let origin = days_from_civil(month.year(), month.month(), 1) - i64::from(offset);
400    std::array::from_fn(|index| {
401        let (year, month, day) = civil_from_days(origin + index as i64);
402        CalendarDate::new(year, month, day).unwrap()
403    })
404}
405
406#[tessera]
407fn calendar(
408    state: State<DatePickerState>,
409    first: Weekday,
410    today: CalendarDate,
411    change: CallbackWith<CalendarDate>,
412    focus_requester: Option<tessera_ui::FocusRequester>,
413) {
414    let snapshot = state.with(DatePickerState::snapshot);
415    let theme = use_context::<MaterialTheme>().unwrap().get();
416    let owner = CalendarOwner::Single(state);
417    let initial_date = owner.initial_date(today);
418    let focus = remember(move || CalendarFocus::new(initial_date, focus_requester));
419    let keyboard = CalendarKeyboard {
420        focus,
421        owner,
422        first_day: first,
423    };
424    keyboard.sync(today);
425    column()
426        .modifier(Modifier::new().fill_max_width().height(Dp(336.0)))
427        .children(move || {
428            layout()
429                .policy(DateGridLayout {
430                    columns: DATE_COLUMNS,
431                    row_height: DATE_CELL_SIZE,
432                })
433                .child(move || {
434                    for weekday in weekday_sequence(first) {
435                        boxed()
436                            .alignment(Alignment::Center)
437                            .modifier(Modifier::new().size(DATE_CELL_SIZE, DATE_CELL_SIZE))
438                            .children(move || {
439                                text(weekday_narrow_label(weekday))
440                                    .style(theme.typography.body_large)
441                                    .color(theme.color_scheme.on_surface);
442                            });
443                    }
444                });
445            layout()
446                .policy(DateGridLayout {
447                    columns: DATE_COLUMNS,
448                    row_height: DATE_CELL_SIZE,
449                })
450                .modifier(
451                    Modifier::new()
452                        .focus_restorer(Some(keyboard.focus_requester()))
453                        .semantics(SemanticsArgs {
454                            role: Some(Role::Group),
455                            label: Some(super::format_month_year(snapshot.displayed_month)),
456                            merge_descendants: true,
457                            ..Default::default()
458                        }),
459                )
460                .child(move || {
461                    for date in cells(snapshot.displayed_month, first) {
462                        day(state, date, today, keyboard, change);
463                    }
464                });
465        });
466}
467
468#[tessera]
469fn day(
470    state: State<DatePickerState>,
471    date: CalendarDate,
472    today: CalendarDate,
473    keyboard: CalendarKeyboard,
474    change: CallbackWith<CalendarDate>,
475) {
476    let snapshot = state.with(DatePickerState::snapshot);
477    calendar_day(
478        CallbackWith::new(move |date| {
479            if state.with_mut(|state| state.set_selected_date(date)) {
480                change.call(date);
481            }
482        }),
483        today,
484    )
485    .cell(date)
486    .selected(snapshot.selected_date == Some(date))
487    .outside_month(
488        date.month() != snapshot.displayed_month.month()
489            || date.year() != snapshot.displayed_month.year(),
490    )
491    .enabled(is_date_selectable(
492        date,
493        &snapshot.year_range,
494        snapshot.is_selectable_date.as_ref(),
495        snapshot.is_selectable_year.as_ref(),
496    ))
497    .keyboard(keyboard);
498}
499
500#[tessera]
501fn month_menu(state: State<DatePickerState>, menu: State<DockedDatePickerView>) {
502    let current = state.with(DatePickerState::displayed_month);
503    let scroll = remember(move || {
504        let mut scroll = ScrollableController::new();
505        let index = i32::from(current.month()).saturating_sub(4).clamp(0, 5);
506        scroll.set_scroll_position(PxPosition::new(
507            Px::ZERO,
508            -Dp(f64::from(index) * 48.0).to_px(),
509        ));
510        scroll
511    });
512    column()
513        .modifier(
514            Modifier::new()
515                .fill_max_width()
516                .height(Dp(336.0))
517                .vertical_scroll(scroll)
518                .semantics(SemanticsArgs {
519                    role: Some(Role::ListBox),
520                    label: Some(I18N.text("md3-choose-month").into()),
521                    merge_descendants: true,
522                    ..Default::default()
523                }),
524        )
525        .children(move || {
526            for month in 1..=12 {
527                month_item(state, menu, month);
528            }
529        });
530}
531
532#[tessera]
533fn month_item(state: State<DatePickerState>, menu: State<DockedDatePickerView>, month: u8) {
534    let current = state.with(DatePickerState::displayed_month);
535    let year = current.year();
536    let enabled = (1..=super::days_in_month(year, month)).any(|day| {
537        state.with(|state| {
538            is_date_selectable(
539                CalendarDate::new(year, month, day).unwrap(),
540                state.year_range(),
541                state.is_selectable_date().as_ref(),
542                state.is_selectable_year().as_ref(),
543            )
544        })
545    });
546    choice(
547        format_month_name(month).into(),
548        current.month() == month,
549        enabled,
550        Callback::new(move || {
551            state.with_mut(|state| {
552                state.set_displayed_month(
553                    YearMonth::new(state.displayed_month().year(), month).unwrap(),
554                )
555            });
556            menu.set(DockedDatePickerView::Calendar);
557        }),
558    );
559}
560
561#[tessera]
562fn year_menu(state: State<DatePickerState>, menu: State<DockedDatePickerView>) {
563    let current = state.with(DatePickerState::displayed_month);
564    let years = state.with(|state| state.year_range().clone());
565    let first = i64::from(*years.start());
566    let count = (i64::from(*years.end()) - first + 1).max(0) as usize;
567    let scroll = remember(move || {
568        let mut scroll = ScrollableController::new();
569        let index = (i64::from(current.year()) - first - 3).max(0);
570        scroll.set_scroll_position(PxPosition::new(Px::ZERO, -Dp(index as f64 * 48.0).to_px()));
571        scroll
572    });
573    let top = scroll.with(|scroll| -scroll.child_position().y);
574    let start =
575        ((top / Dp(48.0).to_pixels_f32()).floor().max(0.0) as usize).min(count.saturating_sub(1));
576    let end = (start + 9).min(count);
577    column()
578        .modifier(
579            Modifier::new()
580                .fill_max_width()
581                .height(Dp(336.0))
582                .vertical_scroll(scroll)
583                .semantics(SemanticsArgs {
584                    role: Some(Role::ListBox),
585                    label: Some(I18N.text("md3-choose-year").into()),
586                    merge_descendants: true,
587                    ..Default::default()
588                }),
589        )
590        .children(move || {
591            spacer().modifier(Modifier::new().height(Dp(start as f64 * 48.0)));
592            for index in start..end {
593                year_item(state, menu, (first + index as i64) as i32);
594            }
595            spacer().modifier(Modifier::new().height(Dp((count - end) as f64 * 48.0)));
596        });
597}
598
599#[tessera]
600fn year_item(state: State<DatePickerState>, menu: State<DockedDatePickerView>, year: i32) {
601    let current = state.with(DatePickerState::displayed_month);
602    let enabled = state.with(|state| {
603        state
604            .is_selectable_year()
605            .is_none_or(|predicate| predicate.call(year))
606    });
607    choice(
608        year.to_string(),
609        current.year() == year,
610        enabled,
611        Callback::new(move || {
612            state.with_mut(|state| {
613                state.set_displayed_month(
614                    YearMonth::new(year, state.displayed_month().month()).unwrap(),
615                )
616            });
617            menu.set(DockedDatePickerView::Calendar);
618        }),
619    );
620}
621
622#[tessera]
623fn choice(label: String, selected: bool, enabled: bool, action: Callback) {
624    let theme = use_context::<MaterialTheme>().unwrap().get();
625    surface()
626        .modifier(
627            Modifier::new()
628                .fill_max_width()
629                .height(Dp(48.0))
630                .semantics(SemanticsArgs {
631                    selected: Some(selected),
632                    merge_descendants: true,
633                    ..Default::default()
634                }),
635        )
636        .style(
637            if selected {
638                theme.color_scheme.surface_container_highest
639            } else {
640                tessera_ui::Color::TRANSPARENT
641            }
642            .into(),
643        )
644        .enabled(enabled)
645        .on_click_shared(action)
646        .accessibility_role(Role::ListBoxOption)
647        .accessibility_label(label.clone())
648        .content_alignment(Alignment::CenterStart)
649        .child(move || {
650            let label = label.clone();
651            row()
652                .modifier(Modifier::new().padding_symmetric(Dp(16.0), Dp::ZERO))
653                .cross_axis_alignment(CrossAxisAlignment::Center)
654                .children(move || {
655                    if selected {
656                        icon()
657                            .painter(filled::CHECK_SVG)
658                            .size(Dp(24.0))
659                            .tint(theme.color_scheme.on_surface);
660                    } else {
661                        spacer().modifier(Modifier::new().width(Dp(24.0)));
662                    }
663                    spacer().modifier(Modifier::new().width(Dp(16.0)));
664                    text(label.clone())
665                        .style(theme.typography.body_large)
666                        .color(theme.color_scheme.on_surface);
667                });
668        });
669}
670
671#[cfg(test)]
672#[path = "docked_tests.rs"]
673mod tests;