Skip to main content

frust_material/date_picker/
input.rs

1// Ported from `material_3_expressive` v1.0.8's date input field (MIT, © 2026
2// Paa Developments;
3// `tmp/material_3_expressive/lib/components/date_pickers/components/m3e_input_date_picker_form_field.dart`,
4// retrieved 2026-08-20).
5// Upstream: <https://github.com/paadevelopments/material_3_expressive>
6//
7// Porting decisions (documented in the module docs below): validation is live
8// rather than deferred behind Flutter's `AutovalidateMode.disabled`; the parse
9// format is the fixed `en_US` compact one; and there is no autofocus, the
10// framework exposing no programmatic-focus seam.
11
12//! The date picker's **input mode**: [`crate::text_field`] wrapped in
13//! `mm/dd/yyyy` parsing, bounds checking, and the two error strings the
14//! reference's form validator produces.
15//!
16//! [`date_input_field`] is controlled like everything else in this family (see
17//! [`mod@super`]): it shows [`DatePickerState::input_text`] and reports the next
18//! state — the new draft text, plus the parsed selection whenever the text
19//! resolves to a selectable date.
20//!
21//! # Live validation, not deferred
22//!
23//! The reference's dialog builds the field inside a `Form` with
24//! `AutovalidateMode.disabled`, so nothing is flagged until OK is pressed
25//! (`_handleOk` flips it to `always` on the first failure). This port validates
26//! on every keystroke once the field is non-empty, and [`DateInputField::validate`]
27//! turns that off for a caller that wants the deferred shape. The trade is
28//! deliberate: an error appears while a half-typed `08/2` is still being typed,
29//! but the user is never told "no" only after committing, and the picker needs
30//! no extra "has OK been pressed yet" state to carry the reference's
31//! `_RestorableAutovalidateMode`.
32//!
33//! An **empty** field never shows an error — the reference's `acceptEmptyDate`
34//! branch reached from the other side: nothing typed is not yet wrong.
35//!
36//! # No autofocus
37//!
38//! The reference opens input mode with `autofocus: true` so the keyboard is up
39//! immediately. The framework's baseline editable exposes no programmatic-focus
40//! seam ([`crate::search`]'s view documents the same gap), so a user taps the
41//! field once before the keyboard appears.
42
43use std::rc::Rc;
44
45use frust::authoring::{BuildCtx, ChangeFlags, View};
46
47use super::calendar::SelectableDay;
48use super::date::MaterialDate;
49use super::{DatePickerState, DatePickerStrings, OnDatePickerChange};
50use crate::text_field::{TextFieldView, TextFieldWidget, text_field};
51
52/// Why a typed date was refused — the two errors the reference's `_validate`
53/// distinguishes.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum DateInputError {
56    /// The text is not `mm/dd/yyyy` at all (`invalidDateFormatLabel`).
57    Format,
58    /// A real date, but outside the selectable bounds or refused by the
59    /// caller's predicate (`invalidDateRangeLabel`).
60    OutOfRange,
61}
62
63/// Parse `text` as a compact date and check it against `first..=last`.
64///
65/// The bounds half of the reference's `_isValidAcceptableDate`; a caller's
66/// `selectableDayPredicate` is applied on top by
67/// [`DateInputField::parse`], which owns the closure.
68///
69/// ```
70/// use frust_material::{DateInputError, MaterialDate, parse_bounded};
71///
72/// let first = MaterialDate::new(2026, 1, 1);
73/// let last = MaterialDate::new(2026, 12, 31);
74/// assert_eq!(parse_bounded("08/20/2026", first, last), Ok(MaterialDate::new(2026, 8, 20)));
75/// assert_eq!(parse_bounded("08/20/2027", first, last), Err(DateInputError::OutOfRange));
76/// assert_eq!(parse_bounded("nonsense", first, last), Err(DateInputError::Format));
77/// ```
78pub fn parse_bounded(
79    text: &str,
80    first: MaterialDate,
81    last: MaterialDate,
82) -> Result<MaterialDate, DateInputError> {
83    let date = MaterialDate::parse_compact(text).ok_or(DateInputError::Format)?;
84    if date.is_within(first, last) {
85        Ok(date)
86    } else {
87        Err(DateInputError::OutOfRange)
88    }
89}
90
91/// A declarative date input field. See the [module docs](self).
92pub struct DateInputField<State: 'static> {
93    state: DatePickerState,
94    first_date: MaterialDate,
95    last_date: MaterialDate,
96    strings: DatePickerStrings,
97    selectable: Option<SelectableDay>,
98    validate: bool,
99    on_change: OnDatePickerChange<State>,
100}
101
102/// Create a controlled date input field over `state`, bounded by
103/// `first_date..=last_date`, reporting each edit through `on_change`.
104pub fn date_input_field<State: 'static, F>(
105    state: DatePickerState,
106    first_date: MaterialDate,
107    last_date: MaterialDate,
108    on_change: F,
109) -> DateInputField<State>
110where
111    F: Fn(&mut State, DatePickerState) + 'static,
112{
113    let (first_date, last_date) = if last_date < first_date {
114        (last_date, first_date)
115    } else {
116        (first_date, last_date)
117    };
118    DateInputField {
119        state,
120        first_date,
121        last_date,
122        strings: DatePickerStrings::ENGLISH,
123        selectable: None,
124        validate: true,
125        on_change: Rc::new(on_change),
126    }
127}
128
129impl<State: 'static> DateInputField<State> {
130    /// Replace the localized strings (default [`DatePickerStrings::ENGLISH`]).
131    pub fn strings(mut self, strings: DatePickerStrings) -> Self {
132        self.strings = strings;
133        self
134    }
135
136    /// Refuse individual dates inside the bounds — the reference's
137    /// `selectableDayPredicate`, reported as [`DateInputError::OutOfRange`].
138    pub fn selectable<F: Fn(MaterialDate) -> bool + 'static>(mut self, predicate: F) -> Self {
139        self.selectable = Some(Rc::new(predicate));
140        self
141    }
142
143    /// Whether the field shows its error while typing (default `true`). `false`
144    /// is the reference's deferred `AutovalidateMode.disabled` shape — see the
145    /// [module docs](self).
146    pub fn validate(mut self, validate: bool) -> Self {
147        self.validate = validate;
148        self
149    }
150
151    /// Parse `text` against this field's bounds *and* its predicate.
152    pub fn parse(&self, text: &str) -> Result<MaterialDate, DateInputError> {
153        let date = parse_bounded(text, self.first_date, self.last_date)?;
154        if self.selectable.as_ref().is_none_or(|p| p(date)) {
155            Ok(date)
156        } else {
157            Err(DateInputError::OutOfRange)
158        }
159    }
160
161    /// The error string this field currently shows, if any: `None` for an empty
162    /// field, for a valid date, or when [`Self::validate`] is off.
163    pub fn error(&self) -> Option<&'static str> {
164        if !self.validate || self.state.input_text.trim().is_empty() {
165            return None;
166        }
167        match self.parse(&self.state.input_text) {
168            Ok(_) => None,
169            Err(DateInputError::Format) => Some(self.strings.error_format),
170            Err(DateInputError::OutOfRange) => Some(self.strings.error_invalid),
171        }
172    }
173
174    /// The state typing `text` into this field requests: the draft text
175    /// recorded always, and the selection updated too whenever `text` resolves
176    /// to a selectable date.
177    ///
178    /// The draft text is re-applied *after* the selection so the user's own
179    /// keystrokes survive — [`DatePickerState::with_selected`] would otherwise
180    /// reformat the field out from under them.
181    pub fn next_state(&self, text: &str) -> DatePickerState {
182        match self.parse(text) {
183            Ok(date) => self.state.with_selected(date).with_input_text(text),
184            Err(_) => self.state.with_input_text(text),
185        }
186    }
187
188    /// Compose the wrapped [`crate::text_field`] fresh from the current fields
189    /// — the same recompose-per-pass shape [`crate::dialog`]'s own views take.
190    fn compose(&self) -> TextFieldView<State> {
191        let parsed = Parsed {
192            state: self.state.clone(),
193            first: self.first_date,
194            last: self.last_date,
195            selectable: self.selectable.clone(),
196        };
197        let on_change = self.on_change.clone();
198        let field = text_field(
199            self.state.input_text.clone(),
200            move |app: &mut State, text: String| on_change(app, parsed.next_state(&text)),
201        )
202        .label(self.strings.field_label);
203        match self.error() {
204            Some(message) => field.error_text(message),
205            None => field.supporting_text(self.strings.field_hint),
206        }
207    }
208}
209
210/// The parse inputs a composed field's `on_change` closure has to own (it
211/// outlives the view it was built from).
212struct Parsed {
213    state: DatePickerState,
214    first: MaterialDate,
215    last: MaterialDate,
216    selectable: Option<SelectableDay>,
217}
218
219impl Parsed {
220    fn next_state(&self, text: &str) -> DatePickerState {
221        match parse_bounded(text, self.first, self.last) {
222            Ok(date) if self.selectable.as_ref().is_none_or(|p| p(date)) => {
223                self.state.with_selected(date).with_input_text(text)
224            }
225            _ => self.state.with_input_text(text),
226        }
227    }
228}
229
230impl<State: 'static> View<State> for DateInputField<State> {
231    type Element = TextFieldWidget;
232
233    fn build(&self, ctx: &mut BuildCtx<'_>) -> TextFieldWidget {
234        View::build(&self.compose(), ctx)
235    }
236
237    fn rebuild(
238        &self,
239        prev: &Self,
240        element: &mut TextFieldWidget,
241        ctx: &mut BuildCtx<'_>,
242    ) -> ChangeFlags {
243        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
244    }
245
246    fn teardown(&self, element: &mut TextFieldWidget, ctx: &mut BuildCtx<'_>) {
247        View::teardown(&self.compose(), element, ctx);
248    }
249}
250
251/// The retained widget for a [`DateInputField`] — the wrapped baseline-backed
252/// [`crate::text_field`]'s own, since this view contributes decoration only.
253pub type DateInputFieldWidget = TextFieldWidget;
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn d(year: i32, month: u32, day: u32) -> MaterialDate {
260        MaterialDate::new(year, month, day)
261    }
262
263    const FIRST: fn() -> MaterialDate = || MaterialDate::new(2020, 1, 1);
264    const LAST: fn() -> MaterialDate = || MaterialDate::new(2030, 12, 31);
265
266    #[derive(Default)]
267    struct App {
268        reported: Vec<DatePickerState>,
269    }
270
271    fn field(state: DatePickerState) -> DateInputField<App> {
272        date_input_field(state, FIRST(), LAST(), |app: &mut App, next| {
273            app.reported.push(next)
274        })
275    }
276
277    fn seeded() -> DatePickerState {
278        DatePickerState::new(None, d(2026, 8, 20))
279    }
280
281    // ---- parsing ---------------------------------------------------------
282
283    #[test]
284    fn parsing_splits_format_errors_from_range_errors() {
285        let first = d(2026, 1, 1);
286        let last = d(2026, 12, 31);
287        assert_eq!(parse_bounded("06/15/2026", first, last), Ok(d(2026, 6, 15)));
288        assert_eq!(parse_bounded("6/5/2026", first, last), Ok(d(2026, 6, 5)));
289        // Both bounds are inclusive.
290        assert_eq!(parse_bounded("01/01/2026", first, last), Ok(first));
291        assert_eq!(parse_bounded("12/31/2026", first, last), Ok(last));
292
293        assert_eq!(
294            parse_bounded("12/31/2025", first, last),
295            Err(DateInputError::OutOfRange)
296        );
297        assert_eq!(
298            parse_bounded("01/01/2027", first, last),
299            Err(DateInputError::OutOfRange)
300        );
301        for bad in ["", "08/2", "2026-06-15", "13/01/2026", "02/30/2026", "abc"] {
302            assert_eq!(
303                parse_bounded(bad, first, last),
304                Err(DateInputError::Format),
305                "{bad:?}"
306            );
307        }
308    }
309
310    #[test]
311    fn a_predicate_refusal_reads_as_out_of_range() {
312        let f = field(seeded()).selectable(|date| date.day() % 2 == 0);
313        assert_eq!(f.parse("06/16/2026"), Ok(d(2026, 6, 16)));
314        assert_eq!(f.parse("06/15/2026"), Err(DateInputError::OutOfRange));
315    }
316
317    #[test]
318    fn reversed_bounds_are_normalized_rather_than_asserted() {
319        let f = date_input_field(seeded(), LAST(), FIRST(), |app: &mut App, next| {
320            app.reported.push(next)
321        });
322        assert_eq!(f.parse("06/15/2026"), Ok(d(2026, 6, 15)));
323    }
324
325    // ---- error text -------------------------------------------------------
326
327    #[test]
328    fn an_empty_field_never_errors() {
329        assert_eq!(field(seeded()).error(), None);
330        assert_eq!(field(seeded().with_input_text("   ")).error(), None);
331    }
332
333    #[test]
334    fn the_two_errors_map_onto_the_reference_strings() {
335        let strings = DatePickerStrings::ENGLISH;
336        assert_eq!(
337            field(seeded().with_input_text("08/2")).error(),
338            Some(strings.error_format)
339        );
340        assert_eq!(
341            field(seeded().with_input_text("08/20/2045")).error(),
342            Some(strings.error_invalid)
343        );
344        assert_eq!(field(seeded().with_input_text("08/20/2026")).error(), None);
345    }
346
347    #[test]
348    fn validation_off_shows_nothing_at_all() {
349        let f = field(seeded().with_input_text("nonsense")).validate(false);
350        assert_eq!(f.error(), None);
351        // The parse itself still answers — only the surfaced error is gated.
352        assert_eq!(f.parse("nonsense"), Err(DateInputError::Format));
353    }
354
355    #[test]
356    fn an_override_supplies_its_own_error_strings() {
357        let f = field(seeded().with_input_text("08/2")).strings(DatePickerStrings {
358            error_format: "Format invalide.",
359            ..DatePickerStrings::ENGLISH
360        });
361        assert_eq!(f.error(), Some("Format invalide."));
362    }
363
364    // ---- reported state ---------------------------------------------------
365
366    #[test]
367    fn a_valid_entry_reports_the_date_and_keeps_the_typed_text() {
368        let f = field(seeded());
369        let next = f.next_state("06/15/2026");
370        assert_eq!(next.selected, Some(d(2026, 6, 15)));
371        assert_eq!(next.displayed_month, d(2026, 6, 1));
372        assert_eq!(
373            next.input_text, "06/15/2026",
374            "the user's own keystrokes survive the reformat"
375        );
376    }
377
378    #[test]
379    fn an_unpadded_entry_keeps_its_own_text_rather_than_reformatting() {
380        let next = field(seeded()).next_state("6/5/2026");
381        assert_eq!(next.selected, Some(d(2026, 6, 5)));
382        assert_eq!(next.input_text, "6/5/2026");
383    }
384
385    #[test]
386    fn a_half_typed_entry_records_the_text_and_nothing_else() {
387        let start = seeded().with_selected(d(2026, 8, 20));
388        let next = field(start.clone()).next_state("08/2");
389        assert_eq!(next.input_text, "08/2");
390        assert_eq!(
391            next.selected, start.selected,
392            "an unparseable draft never clears the selection"
393        );
394        assert_eq!(next.displayed_month, start.displayed_month);
395    }
396
397    #[test]
398    fn an_out_of_range_entry_records_the_text_and_nothing_else() {
399        let next = field(seeded()).next_state("08/20/2045");
400        assert_eq!(next.input_text, "08/20/2045");
401        assert_eq!(next.selected, None);
402    }
403
404    #[test]
405    fn a_refused_entry_records_the_text_and_nothing_else() {
406        let f = field(seeded()).selectable(|date| date.day() % 2 == 0);
407        let next = f.next_state("06/15/2026");
408        assert_eq!(next.input_text, "06/15/2026");
409        assert_eq!(next.selected, None);
410    }
411
412    // ---- composition ------------------------------------------------------
413
414    #[test]
415    fn the_parse_inputs_a_composed_closure_owns_report_the_same_state() {
416        // `compose()`'s `on_change` closure captures a `Parsed` rather than the
417        // view (which does not outlive the pass). It must answer identically to
418        // the view's own `next_state`, which every case above pins.
419        let cases = ["06/15/2026", "6/5/2026", "08/2", "08/20/2045", ""];
420        for text in cases {
421            let view = field(seeded().with_selected(d(2026, 8, 20)));
422            let parsed = Parsed {
423                state: view.state.clone(),
424                first: view.first_date,
425                last: view.last_date,
426                selectable: view.selectable.clone(),
427            };
428            assert_eq!(parsed.next_state(text), view.next_state(text), "{text:?}");
429        }
430    }
431
432    #[test]
433    fn a_predicate_reaches_the_composed_closure_too() {
434        let view = field(seeded()).selectable(|date| date.day() % 2 == 0);
435        let parsed = Parsed {
436            state: view.state.clone(),
437            first: view.first_date,
438            last: view.last_date,
439            selectable: view.selectable.clone(),
440        };
441        assert_eq!(parsed.next_state("06/15/2026").selected, None);
442        assert_eq!(
443            parsed.next_state("06/16/2026").selected,
444            Some(d(2026, 6, 16))
445        );
446    }
447
448    #[test]
449    fn the_view_builds_its_wrapped_field() {
450        // Exercises `compose()` end to end: a real `View::build` over the
451        // wrapped baseline-backed field.
452        let mut counter = 0u64;
453        let view = field(seeded().with_input_text("08/2"));
454        let mut element = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
455        // ...and a rebuild against a changed draft reconciles rather than
456        // rebuilding from scratch.
457        let next = field(seeded().with_input_text("08/20/2026"));
458        let flags =
459            View::<App>::rebuild(&next, &view, &mut element, &mut BuildCtx::new(&mut counter));
460        assert!(!flags.is_empty(), "a value change dirties the field");
461    }
462}