1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41pub enum DockedDatePickerView {
42 #[default]
44 Calendar,
45 Month,
47 Year,
49}
50
51#[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;