Skip to main content

tessera_components/date_picker/
range.rs

1//! Calendar and manual input for inclusive date ranges.
2//!
3//! ## Usage
4//!
5//! Select a booking period, reporting interval, or multi-day event.
6
7use 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
48/// Selection, date restrictions, visible month, and input mode for a date
49/// range.
50///
51/// A range includes both endpoints. Date restrictions apply to its endpoints;
52/// unavailable dates between them do not split the selected range.
53pub struct DateRangePickerState {
54    calendar: DatePickerState,
55    end: Option<CalendarDate>,
56    selection_revision: u64,
57    displayed_revision: u64,
58}
59
60impl DateRangePickerState {
61    /// Creates a range picker. Invalid initial endpoints are discarded.
62    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    /// Returns the inclusive start date, including an unfinished selection.
92    pub fn selected_start_date(&self) -> Option<CalendarDate> {
93        self.calendar.selected_date()
94    }
95
96    /// Returns the inclusive end date when the selection is complete.
97    pub fn selected_end_date(&self) -> Option<CalendarDate> {
98        self.end
99    }
100
101    /// Returns whether both endpoints are selected.
102    pub fn is_complete(&self) -> bool {
103        self.selected_start_date().is_some() && self.end.is_some()
104    }
105
106    /// Sets both endpoints atomically, returning false without changing an
107    /// invalid range.
108    ///
109    /// An end requires a start on or before it. Both endpoints must be
110    /// selectable.
111    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    /// Selects the next endpoint, restarting after a complete range or an
129    /// earlier date.
130    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    /// Clears both endpoints without moving the calendar.
146    pub fn clear_selection(&mut self) {
147        self.set_selection(None, None);
148    }
149
150    /// Returns whether the date can be used as an endpoint.
151    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    /// Returns the allowed year range.
161    pub fn year_range(&self) -> &RangeInclusive<i32> {
162        self.calendar.year_range()
163    }
164
165    /// Updates the year range, clearing endpoints that are no longer
166    /// selectable.
167    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    /// Updates endpoint date restrictions, clearing invalid selections.
173    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    /// Updates endpoint year restrictions, clearing invalid selections.
179    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    /// Returns the first visible calendar month.
185    pub fn displayed_month(&self) -> YearMonth {
186        self.calendar.displayed_month()
187    }
188
189    /// Scrolls to a month, clamped to the allowed year range.
190    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    /// Returns the calendar or manual-input mode.
196    pub fn display_mode(&self) -> DatePickerDisplayMode {
197        self.calendar.display_mode()
198    }
199
200    /// Changes input mode, showing the selected start month when returning to
201    /// the calendar.
202    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    /// Switches between calendar and manual input.
214    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/// # date_range_picker
250///
251/// Select an inclusive date range for bookings, reports, and multi-day events.
252///
253/// ## Usage
254///
255/// Collect a start and end date with a scrolling calendar or two date inputs.
256///
257/// ## Parameters
258///
259/// - `state` — optional external selection, restrictions, visible month, and
260///   mode.
261/// - `modifier` — sizing and layout of the picker body.
262/// - `first_day_of_week` — weekday at the start of each calendar row.
263/// - `today` — current date used for the date outline and initial keyboard
264///   focus; defaults to the system date.
265/// - `calendar_height` — scrollable month viewport height; defaults to 360dp.
266/// - `show_mode_toggle` — whether people can switch calendar and input modes.
267/// - `title` — optional title override.
268/// - `headline` — optional selection-summary override.
269///
270/// ## Examples
271///
272/// ```
273/// use tessera_components::date_picker::{CalendarDate, DateRangePickerState, date_range_picker};
274/// use tessera_components::theme::material_theme;
275/// use tessera_ui::{remember, tessera};
276/// #[tessera]
277/// fn demo() {
278///     let state = remember(DateRangePickerState::default);
279///     material_theme().child(move || {
280///         date_range_picker()
281///             .state(state)
282///             .today(CalendarDate::new(2026, 7, 16).unwrap());
283///     });
284/// }
285/// let mut state = DateRangePickerState::default();
286/// let day = CalendarDate::new(2026, 10, 17).unwrap();
287/// assert!(state.select_date(day));
288/// assert!(!state.is_complete());
289/// assert!(state.select_date(day));
290/// assert!(state.is_complete());
291/// # tessera_ui::testing::layout_test(demo).run();
292/// ```
293#[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}