Skip to main content

fret_ui_headless/
calendar.rs

1use std::sync::Arc;
2
3use time::{Date, Duration, Month, Weekday};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct DateRange {
7    pub start: Date,
8    pub end: Date,
9}
10
11impl DateRange {
12    pub fn new(a: Date, b: Date) -> Self {
13        if a <= b {
14            Self { start: a, end: b }
15        } else {
16            Self { start: b, end: a }
17        }
18    }
19
20    pub fn contains(&self, date: Date) -> bool {
21        self.start <= date && date <= self.end
22    }
23}
24
25/// A DayPicker-like date range selection state (supports partial selection).
26#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
27pub struct DateRangeSelection {
28    pub from: Option<Date>,
29    pub to: Option<Date>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum SelectionUpdate<T> {
34    NoChange,
35    Set(T),
36}
37
38impl<T> SelectionUpdate<T> {
39    pub fn is_change(&self) -> bool {
40        matches!(self, Self::Set(_))
41    }
42}
43
44/// A subset of `react-day-picker`'s `Matcher` union, expressed in `time::Date`.
45///
46/// Upstream reference: `react-day-picker/src/types/shared.ts` (`export type Matcher = ...`).
47#[derive(Clone)]
48pub enum DayMatcher {
49    Bool(bool),
50    Predicate(Arc<dyn Fn(Date) -> bool + Send + Sync + 'static>),
51    Date(Date),
52    Dates(Arc<[Date]>),
53    DateRange(DateRangeSelection),
54    DateBefore { before: Date },
55    DateAfter { after: Date },
56    DateInterval { before: Date, after: Date },
57    DayOfWeek(Arc<[Weekday]>),
58}
59
60impl std::fmt::Debug for DayMatcher {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::Bool(v) => f.debug_tuple("Bool").field(v).finish(),
64            Self::Predicate(_) => f.debug_tuple("Predicate").field(&"<fn>").finish(),
65            Self::Date(d) => f.debug_tuple("Date").field(d).finish(),
66            Self::Dates(ds) => f.debug_tuple("Dates").field(ds).finish(),
67            Self::DateRange(r) => f.debug_tuple("DateRange").field(r).finish(),
68            Self::DateBefore { before } => f
69                .debug_struct("DateBefore")
70                .field("before", before)
71                .finish(),
72            Self::DateAfter { after } => f.debug_struct("DateAfter").field("after", after).finish(),
73            Self::DateInterval { before, after } => f
74                .debug_struct("DateInterval")
75                .field("before", before)
76                .field("after", after)
77                .finish(),
78            Self::DayOfWeek(days) => f.debug_tuple("DayOfWeek").field(days).finish(),
79        }
80    }
81}
82
83impl DayMatcher {
84    pub fn day_of_week(day: Weekday) -> Self {
85        Self::DayOfWeek(Arc::from([day]))
86    }
87
88    pub fn day_of_week_any(days: impl Into<Arc<[Weekday]>>) -> Self {
89        Self::DayOfWeek(days.into())
90    }
91
92    pub fn dates(dates: impl Into<Arc<[Date]>>) -> Self {
93        Self::Dates(dates.into())
94    }
95
96    pub fn is_match(&self, date: Date) -> bool {
97        // Mirrors `react-day-picker` `dateMatchModifiers()` semantics.
98        match self {
99            Self::Bool(v) => *v,
100            Self::Predicate(p) => p(date),
101            Self::Date(d) => *d == date,
102            Self::Dates(ds) => ds.contains(&date),
103            Self::DateRange(r) => range_includes_date(*r, date, false),
104            Self::DayOfWeek(days) => days.iter().any(|d| *d == date.weekday()),
105            Self::DateInterval { before, after } => {
106                let diff_before = (*before - date).whole_days();
107                let diff_after = (*after - date).whole_days();
108                let is_day_before = diff_before > 0;
109                let is_day_after = diff_after < 0;
110                let is_closed_interval = *before > *after;
111                if is_closed_interval {
112                    is_day_after && is_day_before
113                } else {
114                    is_day_before || is_day_after
115                }
116            }
117            Self::DateAfter { after } => (date - *after).whole_days() > 0,
118            Self::DateBefore { before } => (*before - date).whole_days() > 0,
119        }
120    }
121}
122
123/// Mirrors `react-day-picker` `rangeIncludesDate()` behavior (inclusive ends by default).
124pub fn range_includes_date(range: DateRangeSelection, date: Date, exclude_ends: bool) -> bool {
125    let mut from = range.from;
126    let mut to = range.to;
127    if let (Some(f), Some(t)) = (from, to)
128        && t < f
129    {
130        (from, to) = (Some(t), Some(f));
131    }
132
133    match (from, to) {
134        (Some(f), Some(t)) => {
135            let left = (date - f).whole_days();
136            let right = (t - date).whole_days();
137            let min = if exclude_ends { 1 } else { 0 };
138            left >= min && right >= min
139        }
140        (None, Some(t)) if !exclude_ends => t == date,
141        (Some(f), None) if !exclude_ends => f == date,
142        _ => false,
143    }
144}
145
146/// A headless representation of `react-day-picker`'s "modifiers" input, focusing on the
147/// built-in `disabled` and `hidden` buckets.
148#[derive(Debug, Default, Clone)]
149pub struct DayPickerModifiers {
150    pub disabled: Vec<DayMatcher>,
151    pub hidden: Vec<DayMatcher>,
152}
153
154impl DayPickerModifiers {
155    pub fn disabled_by(mut self, matcher: DayMatcher) -> Self {
156        self.disabled.push(matcher);
157        self
158    }
159
160    pub fn hidden_by(mut self, matcher: DayMatcher) -> Self {
161        self.hidden.push(matcher);
162        self
163    }
164}
165
166#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
167pub struct DayPickerDayModifiers {
168    pub outside: bool,
169    pub disabled: bool,
170    pub hidden: bool,
171}
172
173pub fn day_picker_day_modifiers(
174    day: CalendarDay,
175    show_outside_days: bool,
176    modifiers: &DayPickerModifiers,
177) -> DayPickerDayModifiers {
178    let outside = !day.in_month;
179    let hidden =
180        (!show_outside_days && outside) || modifiers.hidden.iter().any(|m| m.is_match(day.date));
181    let disabled = modifiers.disabled.iter().any(|m| m.is_match(day.date));
182    DayPickerDayModifiers {
183        outside,
184        disabled,
185        hidden,
186    }
187}
188
189/// A composed day-cell state for DayPicker-like views.
190///
191/// This combines:
192/// - `modifiers.disabled` / `modifiers.hidden`
193/// - outside-day policy (`show_outside_days`, `disable_outside_days`)
194/// - caller-provided bounds checks (`in_bounds`)
195///
196/// Notes:
197/// - Hidden days are always treated as disabled.
198#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
199pub struct DayPickerCellState {
200    pub hidden: bool,
201    pub disabled: bool,
202}
203
204pub fn day_picker_cell_state(
205    day: CalendarDay,
206    show_outside_days: bool,
207    disable_outside_days: bool,
208    in_bounds: bool,
209    modifiers: &DayPickerModifiers,
210) -> DayPickerCellState {
211    let base = day_picker_day_modifiers(day, show_outside_days, modifiers);
212    // Align with react-day-picker/shadcn behavior:
213    // - days outside `startMonth`/`endMonth` can be suppressed from the grid
214    // - keep in-month days visible but disabled when out of bounds (common for date-range bounds)
215    let hidden = base.hidden || (!in_bounds && !day.in_month);
216
217    let mut disabled = base.disabled || (!day.in_month && disable_outside_days) || !in_bounds;
218    if hidden {
219        disabled = true;
220    }
221
222    DayPickerCellState { hidden, disabled }
223}
224
225pub fn day_grid_step_target_skipping_disabled(
226    current: usize,
227    len: usize,
228    step: i32,
229    disabled: &[bool],
230) -> usize {
231    if len == 0 || step == 0 || current >= len {
232        return current;
233    }
234
235    let mut idx = (current as i32 + step).clamp(0, (len.saturating_sub(1)) as i32) as usize;
236    if idx == current {
237        return current;
238    }
239
240    while idx != current && disabled.get(idx).copied().unwrap_or(true) {
241        let next = idx as i32 + step;
242        if next < 0 || next >= len as i32 {
243            break;
244        }
245        idx = next as usize;
246    }
247
248    if idx != current && !disabled.get(idx).copied().unwrap_or(true) {
249        idx
250    } else {
251        current
252    }
253}
254
255pub fn day_grid_row_edge_target_skipping_disabled(
256    current: usize,
257    len: usize,
258    target: usize,
259    disabled: &[bool],
260) -> usize {
261    if len == 0 || current >= len {
262        return current;
263    }
264
265    let row_start = (current / 7) * 7;
266    let row_end = (row_start + 6).min(len.saturating_sub(1));
267    let target = target.clamp(row_start, row_end);
268
269    if !disabled.get(target).copied().unwrap_or(true) {
270        return target;
271    }
272
273    if target == row_start {
274        for idx in row_start..=row_end {
275            if !disabled.get(idx).copied().unwrap_or(true) {
276                return idx;
277            }
278        }
279    } else if target == row_end {
280        for idx in (row_start..=row_end).rev() {
281            if !disabled.get(idx).copied().unwrap_or(true) {
282                return idx;
283            }
284        }
285    }
286
287    current
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum DayPickerGridLayout {
292    Compact,
293    FixedWeeks,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct DayPickerGridOptions {
298    pub week_start: Weekday,
299    pub layout: DayPickerGridLayout,
300}
301
302impl Default for DayPickerGridOptions {
303    fn default() -> Self {
304        Self {
305            week_start: Weekday::Monday,
306            layout: DayPickerGridLayout::Compact,
307        }
308    }
309}
310
311/// Build the month grid using `react-day-picker`-like options.
312///
313/// - `Compact`: variable number of weeks (default for shadcn's Calendar).
314/// - `FixedWeeks`: always 6 weeks (42 days), useful for stable layouts.
315pub fn day_picker_month_grid(
316    month: CalendarMonth,
317    options: DayPickerGridOptions,
318) -> Vec<CalendarDay> {
319    match options.layout {
320        DayPickerGridLayout::Compact => month_grid_compact(month, options.week_start),
321        DayPickerGridLayout::FixedWeeks => month_grid(month, options.week_start).to_vec(),
322    }
323}
324
325/// Mirrors `react-day-picker` `useSingle` selection behavior.
326pub fn day_picker_select_single(
327    trigger: Date,
328    current: Option<Date>,
329    required: bool,
330) -> SelectionUpdate<Option<Date>> {
331    let mut next = Some(trigger);
332    if !required && current.is_some_and(|d| d == trigger) {
333        next = None;
334    }
335
336    if next == current {
337        SelectionUpdate::NoChange
338    } else {
339        SelectionUpdate::Set(next)
340    }
341}
342
343/// Mirrors `react-day-picker` `useMulti` selection behavior.
344pub fn day_picker_select_multi(
345    trigger: Date,
346    current: &[Date],
347    required: bool,
348    min: Option<usize>,
349    max: Option<usize>,
350) -> SelectionUpdate<Vec<Date>> {
351    let min = min.unwrap_or(0);
352    let max = max.unwrap_or(0);
353
354    let is_selected = current.contains(&trigger);
355
356    if is_selected {
357        if current.len() == min {
358            return SelectionUpdate::NoChange;
359        }
360        if required && current.len() == 1 {
361            return SelectionUpdate::NoChange;
362        }
363        let next = current
364            .iter()
365            .copied()
366            .filter(|d| *d != trigger)
367            .collect::<Vec<_>>();
368        if next == current {
369            SelectionUpdate::NoChange
370        } else {
371            SelectionUpdate::Set(next)
372        }
373    } else {
374        let next = if max > 0 && current.len() == max {
375            vec![trigger]
376        } else {
377            let mut next = current.to_vec();
378            next.push(trigger);
379            next
380        };
381        if next == current {
382            SelectionUpdate::NoChange
383        } else {
384            SelectionUpdate::Set(next)
385        }
386    }
387}
388
389/// Mirrors `react-day-picker` `addToRange()` selection behavior (including the
390/// min/max "reset to start" logic).
391///
392/// Notes:
393/// - `min_days`/`max_days` are "calendar-day differences" between `from` and
394///   `to`, matching `date-fns` `differenceInCalendarDays` used upstream.
395/// - When `exclude_disabled` is `true`, the returned range will reset to
396///   `{ from: trigger, to: None }` if any day in the candidate range matches
397///   `disabled_predicate`.
398pub fn day_picker_add_to_range(
399    trigger: Date,
400    current: DateRangeSelection,
401    min_days: i64,
402    max_days: i64,
403    required: bool,
404    exclude_disabled: bool,
405    disabled_predicate: Option<&dyn Fn(Date) -> bool>,
406) -> SelectionUpdate<DateRangeSelection> {
407    let mut from = current.from;
408    let mut to = current.to;
409
410    // If the state is somehow "to without from", treat it as empty.
411    if from.is_none() && to.is_some() {
412        from = None;
413        to = None;
414    }
415
416    let mut next: Option<DateRangeSelection> = match (from, to) {
417        (None, None) => Some(DateRangeSelection {
418            from: Some(trigger),
419            to: if min_days > 0 { None } else { Some(trigger) },
420        }),
421        (Some(f), None) => {
422            if f == trigger {
423                if min_days == 0 {
424                    Some(DateRangeSelection {
425                        from: Some(f),
426                        to: Some(trigger),
427                    })
428                } else if required {
429                    Some(DateRangeSelection {
430                        from: Some(f),
431                        to: None,
432                    })
433                } else {
434                    None
435                }
436            } else if trigger < f {
437                Some(DateRangeSelection {
438                    from: Some(trigger),
439                    to: Some(f),
440                })
441            } else {
442                Some(DateRangeSelection {
443                    from: Some(f),
444                    to: Some(trigger),
445                })
446            }
447        }
448        (Some(f), Some(t)) => {
449            if f == trigger && t == trigger {
450                if required {
451                    Some(DateRangeSelection {
452                        from: Some(f),
453                        to: Some(t),
454                    })
455                } else {
456                    None
457                }
458            } else if f == trigger {
459                Some(DateRangeSelection {
460                    from: Some(f),
461                    to: if min_days > 0 { None } else { Some(trigger) },
462                })
463            } else if t == trigger {
464                Some(DateRangeSelection {
465                    from: Some(trigger),
466                    to: if min_days > 0 { None } else { Some(trigger) },
467                })
468            } else if trigger < f {
469                Some(DateRangeSelection {
470                    from: Some(trigger),
471                    to: Some(t),
472                })
473            } else if trigger > f {
474                Some(DateRangeSelection {
475                    from: Some(f),
476                    to: Some(trigger),
477                })
478            } else {
479                // Mirrors upstream "Invalid range" branch: keep the current value.
480                Some(DateRangeSelection {
481                    from: Some(f),
482                    to: Some(t),
483                })
484            }
485        }
486        (None, Some(_)) => Some(DateRangeSelection {
487            from: Some(trigger),
488            to: if min_days > 0 { None } else { Some(trigger) },
489        }),
490    };
491
492    // Apply min/max constraints (upstream behavior: reset to the start of the range).
493    if let Some(r) = next.as_mut()
494        && let (Some(f), Some(t)) = (r.from, r.to)
495    {
496        let diff_days = (t - f).whole_days();
497        if (max_days > 0 && diff_days > max_days) || (min_days > 1 && diff_days < min_days) {
498            *r = DateRangeSelection {
499                from: Some(trigger),
500                to: None,
501            };
502        }
503    }
504
505    // Apply exclude-disabled behavior (upstream behavior: reset to the start of the range).
506    if exclude_disabled
507        && let Some(pred) = disabled_predicate
508        && let Some(r) = next.as_mut()
509        && let (Some(f), Some(t)) = (r.from, r.to)
510    {
511        let diff_days = (t - f).whole_days();
512        if diff_days >= 0 {
513            for i in 0..=diff_days {
514                let d = f + Duration::days(i);
515                if pred(d) {
516                    *r = DateRangeSelection {
517                        from: Some(trigger),
518                        to: None,
519                    };
520                    break;
521                }
522            }
523        }
524    }
525
526    let next = next.unwrap_or_default();
527    if next == current {
528        SelectionUpdate::NoChange
529    } else {
530        SelectionUpdate::Set(next)
531    }
532}
533
534impl DateRangeSelection {
535    pub fn clear(&mut self) {
536        self.from = None;
537        self.to = None;
538    }
539
540    pub fn is_complete(&self) -> bool {
541        self.from.is_some() && self.to.is_some()
542    }
543
544    pub fn normalized_range(&self) -> Option<DateRange> {
545        Some(DateRange::new(self.from?, self.to?))
546    }
547
548    pub fn is_start(&self, date: Date) -> bool {
549        self.from.is_some_and(|d| d == date)
550            || self.to.is_some_and(|d| d == date && self.from.is_none())
551    }
552
553    pub fn is_end(&self, date: Date) -> bool {
554        self.to.is_some_and(|d| d == date) && self.from.is_some()
555    }
556
557    pub fn contains(&self, date: Date) -> bool {
558        self.normalized_range()
559            .is_some_and(|range| range.contains(date))
560    }
561
562    /// Applies a DayPicker-like click interaction (mirrors upstream
563    /// `react-day-picker` `addToRange()` defaults).
564    ///
565    /// This uses default selection options:
566    /// - `min_days = 0`
567    /// - `max_days = 0`
568    /// - `required = false`
569    /// - `exclude_disabled = false`
570    pub fn apply_click(&mut self, date: Date) {
571        let current = *self;
572        if let SelectionUpdate::Set(next) =
573            day_picker_add_to_range(date, current, 0, 0, false, false, None)
574        {
575            *self = next;
576        }
577    }
578
579    pub fn apply_click_with(
580        &mut self,
581        date: Date,
582        min_days: i64,
583        max_days: i64,
584        required: bool,
585        exclude_disabled: bool,
586        disabled_predicate: Option<&dyn Fn(Date) -> bool>,
587    ) {
588        let current = *self;
589        if let SelectionUpdate::Set(next) = day_picker_add_to_range(
590            date,
591            current,
592            min_days,
593            max_days,
594            required,
595            exclude_disabled,
596            disabled_predicate,
597        ) {
598            *self = next;
599        }
600    }
601}
602
603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
604pub struct CalendarMonth {
605    pub year: i32,
606    pub month: Month,
607}
608
609impl CalendarMonth {
610    pub fn new(year: i32, month: Month) -> Self {
611        Self { year, month }
612    }
613
614    pub fn from_date(date: Date) -> Self {
615        Self {
616            year: date.year(),
617            month: date.month(),
618        }
619    }
620
621    pub fn first_day(&self) -> Date {
622        Date::from_calendar_date(self.year, self.month, 1).expect("valid month")
623    }
624
625    pub fn next_month(&self) -> Self {
626        let (year, month) = if self.month == Month::December {
627            (self.year + 1, Month::January)
628        } else {
629            (self.year, self.month.next())
630        };
631        Self { year, month }
632    }
633
634    pub fn prev_month(&self) -> Self {
635        let (year, month) = if self.month == Month::January {
636            (self.year - 1, Month::December)
637        } else {
638            (self.year, self.month.previous())
639        };
640        Self { year, month }
641    }
642}
643
644#[derive(Debug, Clone, Copy, PartialEq, Eq)]
645pub struct CalendarDay {
646    pub date: Date,
647    pub in_month: bool,
648}
649
650fn weekday_index_from_monday(weekday: Weekday) -> u8 {
651    weekday.number_from_monday()
652}
653
654fn offset_to_week_start(day: Weekday, week_start: Weekday) -> u8 {
655    let a = weekday_index_from_monday(day) as i16;
656    let b = weekday_index_from_monday(week_start) as i16;
657    let diff = a - b;
658    ((diff % 7 + 7) % 7) as u8
659}
660
661/// Builds a 6-week (42-day) calendar grid for the given month.
662///
663/// This matches common date picker UIs:
664/// - always returns 42 days (stable layout)
665/// - includes outside-month days with `in_month=false`
666pub fn month_grid(month: CalendarMonth, week_start: Weekday) -> [CalendarDay; 42] {
667    let first = month.first_day();
668    let start_offset = offset_to_week_start(first.weekday(), week_start) as i64;
669    let grid_start = first - Duration::days(start_offset);
670
671    std::array::from_fn(|i| {
672        let date = grid_start + Duration::days(i as i64);
673        CalendarDay {
674            date,
675            in_month: date.year() == month.year && date.month() == month.month,
676        }
677    })
678}
679
680/// Builds a compact calendar grid for the given month.
681///
682/// This matches `react-day-picker`'s default behavior used by shadcn's `Calendar`:
683/// - the grid is aligned to week boundaries (start at `week_start`, end at the corresponding week end)
684/// - the number of rows is variable (typically 5 or 6; 4 is possible for some Februaries)
685/// - outside-month days are included with `in_month=false`
686pub fn month_grid_compact(month: CalendarMonth, week_start: Weekday) -> Vec<CalendarDay> {
687    let first = month.first_day();
688    let next_first = month.next_month().first_day();
689    let last = next_first - Duration::days(1);
690
691    let start_offset = offset_to_week_start(first.weekday(), week_start) as i64;
692    let grid_start = first - Duration::days(start_offset);
693
694    let week_start_idx = weekday_index_from_monday(week_start) as i16;
695    let week_end_idx = (week_start_idx + 6) % 7;
696    let last_idx = weekday_index_from_monday(last.weekday()) as i16;
697    let end_offset = ((week_end_idx - last_idx) % 7 + 7) % 7;
698    let grid_end = last + Duration::days(end_offset as i64);
699
700    let days = (grid_end - grid_start).whole_days() + 1;
701    debug_assert!(days > 0 && days % 7 == 0);
702
703    (0..days)
704        .map(|i| {
705            let date = grid_start + Duration::days(i);
706            CalendarDay {
707                date,
708                in_month: date.year() == month.year && date.month() == month.month,
709            }
710        })
711        .collect()
712}
713
714fn start_of_week(date: Date, week_start: Weekday) -> Date {
715    let offset = offset_to_week_start(date.weekday(), week_start) as i64;
716    date - Duration::days(offset)
717}
718
719/// Returns a week number aligned to `week_start`, matching `date-fns` `getWeek` defaults
720/// (`firstWeekContainsDate = 1`).
721///
722/// This is the numbering used by `react-day-picker` when `showWeekNumber` is enabled.
723pub fn week_number(date: Date, week_start: Weekday) -> u32 {
724    let week_start_date = start_of_week(date, week_start);
725    let week_end_date = week_start_date + Duration::days(6);
726    let week_year = week_end_date.year();
727
728    let jan1 = Date::from_calendar_date(week_year, Month::January, 1).expect("valid year");
729    let week1_start = start_of_week(jan1, week_start);
730
731    let diff_days = (week_start_date - week1_start).whole_days();
732    let weeks = diff_days.div_euclid(7).max(0);
733    (weeks as u32) + 1
734}
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    #[test]
740    fn day_picker_cell_state_out_of_bounds_is_disabled_not_hidden() {
741        let d = Date::from_calendar_date(2026, Month::January, 1).unwrap();
742        let day = CalendarDay {
743            date: d,
744            in_month: true,
745        };
746
747        let st = day_picker_cell_state(day, true, false, false, &DayPickerModifiers::default());
748        assert!(!st.hidden);
749        assert!(st.disabled);
750    }
751
752    #[test]
753    fn day_picker_cell_state_outside_day_out_of_bounds_is_hidden() {
754        let d = Date::from_calendar_date(2026, Month::February, 1).unwrap();
755        let day = CalendarDay {
756            date: d,
757            in_month: false,
758        };
759
760        let st = day_picker_cell_state(day, true, false, false, &DayPickerModifiers::default());
761        assert!(st.hidden);
762        assert!(st.disabled);
763    }
764
765    #[test]
766    fn month_grid_is_stable_42_days() {
767        let m = CalendarMonth::new(2026, Month::January);
768        let grid = month_grid(m, Weekday::Monday);
769        assert_eq!(grid.len(), 42);
770    }
771
772    #[test]
773    fn month_grid_includes_first_day_and_marks_in_month() {
774        let m = CalendarMonth::new(2026, Month::January);
775        let grid = month_grid(m, Weekday::Monday);
776        let jan1 = Date::from_calendar_date(2026, Month::January, 1).unwrap();
777        assert!(grid.iter().any(|d| d.date == jan1 && d.in_month));
778    }
779
780    #[test]
781    fn month_nav_rolls_year_boundaries() {
782        let dec = CalendarMonth::new(2025, Month::December);
783        assert_eq!(dec.next_month(), CalendarMonth::new(2026, Month::January));
784
785        let jan = CalendarMonth::new(2026, Month::January);
786        assert_eq!(jan.prev_month(), CalendarMonth::new(2025, Month::December));
787    }
788
789    #[test]
790    fn date_range_new_orders_start_end() {
791        let a = Date::from_calendar_date(2026, Month::January, 5).unwrap();
792        let b = Date::from_calendar_date(2026, Month::January, 2).unwrap();
793        let range = DateRange::new(a, b);
794        assert_eq!(range.start, b);
795        assert_eq!(range.end, a);
796        assert!(range.contains(a));
797        assert!(range.contains(b));
798    }
799
800    #[test]
801    fn date_range_selection_click_sequence_matches_daypicker_expectations() {
802        let d1 = Date::from_calendar_date(2026, Month::January, 2).unwrap();
803        let d2 = Date::from_calendar_date(2026, Month::January, 5).unwrap();
804        let d3 = Date::from_calendar_date(2026, Month::January, 8).unwrap();
805
806        let mut sel = DateRangeSelection::default();
807        sel.apply_click(d2);
808        assert_eq!(sel.from, Some(d2));
809        assert_eq!(sel.to, Some(d2));
810        assert!(sel.is_complete());
811
812        sel.apply_click(d1);
813        assert_eq!(sel.from, Some(d1));
814        assert_eq!(sel.to, Some(d2));
815        assert!(sel.is_complete());
816        assert!(sel.contains(d1));
817        assert!(sel.contains(d2));
818
819        sel.apply_click(d3);
820        assert_eq!(sel.from, Some(d1));
821        assert_eq!(sel.to, Some(d3));
822        assert!(sel.is_complete());
823    }
824
825    #[test]
826    fn day_picker_single_optional_toggles_off_on_same_day() {
827        let d1 = Date::from_calendar_date(2026, Month::January, 2).unwrap();
828        assert_eq!(
829            day_picker_select_single(d1, Some(d1), false),
830            SelectionUpdate::Set(None)
831        );
832        assert_eq!(
833            day_picker_select_single(d1, Some(d1), true),
834            SelectionUpdate::NoChange
835        );
836    }
837
838    #[test]
839    fn day_picker_multi_resets_when_max_reached() {
840        let d1 = Date::from_calendar_date(2026, Month::January, 2).unwrap();
841        let d2 = Date::from_calendar_date(2026, Month::January, 3).unwrap();
842        let d3 = Date::from_calendar_date(2026, Month::January, 4).unwrap();
843
844        let cur = vec![d1, d2];
845        assert_eq!(
846            day_picker_select_multi(d3, &cur, false, None, Some(2)),
847            SelectionUpdate::Set(vec![d3])
848        );
849    }
850
851    #[test]
852    fn day_picker_range_min_and_exclude_disabled_match_upstream_intent() {
853        let d1 = Date::from_calendar_date(2026, Month::January, 1).unwrap();
854        let d2 = Date::from_calendar_date(2026, Month::January, 2).unwrap();
855        let d10 = Date::from_calendar_date(2026, Month::January, 10).unwrap();
856
857        // min_days > 0 => first click produces partial selection (to=None).
858        let mut sel = DateRangeSelection::default();
859        sel.apply_click_with(d1, 1, 0, false, false, None);
860        assert_eq!(
861            sel,
862            DateRangeSelection {
863                from: Some(d1),
864                to: None
865            }
866        );
867
868        // exclude_disabled => selecting a range that spans a disabled day resets to start.
869        let disabled = |d: Date| d == d2;
870        let mut sel = DateRangeSelection::default();
871        sel.apply_click_with(d1, 0, 0, false, false, None);
872        sel.apply_click_with(d10, 0, 0, false, true, Some(&disabled));
873        assert_eq!(
874            sel,
875            DateRangeSelection {
876                from: Some(d10),
877                to: None
878            }
879        );
880    }
881
882    #[test]
883    fn day_picker_cell_state_composes_hidden_disabled_outside_and_bounds() {
884        let date = Date::from_calendar_date(2026, Month::January, 2).unwrap();
885
886        let in_month = CalendarDay {
887            date,
888            in_month: true,
889        };
890        let outside = CalendarDay {
891            date,
892            in_month: false,
893        };
894
895        let mut modifiers = DayPickerModifiers::default();
896        modifiers.hidden.push(DayMatcher::Date(date));
897
898        // Hidden always disables.
899        let st = day_picker_cell_state(in_month, true, false, true, &modifiers);
900        assert_eq!(
901            st,
902            DayPickerCellState {
903                hidden: true,
904                disabled: true
905            }
906        );
907
908        // Outside days can be shown but disabled.
909        let st = day_picker_cell_state(outside, true, true, true, &DayPickerModifiers::default());
910        assert_eq!(
911            st,
912            DayPickerCellState {
913                hidden: false,
914                disabled: true
915            }
916        );
917
918        // In-month out-of-bounds days remain visible but disabled.
919        let st =
920            day_picker_cell_state(in_month, true, false, false, &DayPickerModifiers::default());
921        assert_eq!(
922            st,
923            DayPickerCellState {
924                hidden: false,
925                disabled: true
926            }
927        );
928    }
929
930    #[test]
931    fn day_grid_navigation_skips_disabled() {
932        let len = 14;
933        let mut disabled = vec![true; len];
934        for idx in [1usize, 6, 8, 13] {
935            disabled[idx] = false;
936        }
937
938        assert_eq!(
939            day_grid_step_target_skipping_disabled(6, len, 1, &disabled),
940            8
941        );
942        assert_eq!(
943            day_grid_step_target_skipping_disabled(8, len, -1, &disabled),
944            6
945        );
946        assert_eq!(
947            day_grid_step_target_skipping_disabled(1, len, 7, &disabled),
948            8
949        );
950        assert_eq!(
951            day_grid_step_target_skipping_disabled(13, len, -7, &disabled),
952            6
953        );
954
955        let current = 10;
956        let row_start = (current / 7) * 7;
957        let row_end = (row_start + 6).min(len - 1);
958        assert_eq!(
959            day_grid_row_edge_target_skipping_disabled(current, len, row_start, &disabled),
960            8
961        );
962        assert_eq!(
963            day_grid_row_edge_target_skipping_disabled(current, len, row_end, &disabled),
964            13
965        );
966    }
967
968    #[test]
969    fn range_includes_date_matches_endpoints_only_when_open_ended() {
970        let d1 = Date::from_calendar_date(2026, Month::January, 2).unwrap();
971        let d2 = Date::from_calendar_date(2026, Month::January, 3).unwrap();
972        let only_from = DateRangeSelection {
973            from: Some(d1),
974            to: None,
975        };
976        let only_to = DateRangeSelection {
977            from: None,
978            to: Some(d1),
979        };
980
981        assert!(range_includes_date(only_from, d1, false));
982        assert!(!range_includes_date(only_from, d2, false));
983        assert!(range_includes_date(only_to, d1, false));
984        assert!(!range_includes_date(only_to, d2, false));
985    }
986
987    #[test]
988    fn day_matcher_date_interval_excludes_ends() {
989        let after = Date::from_calendar_date(2026, Month::January, 2).unwrap();
990        let before = Date::from_calendar_date(2026, Month::January, 5).unwrap();
991        let mid = Date::from_calendar_date(2026, Month::January, 3).unwrap();
992        let m = DayMatcher::DateInterval { before, after };
993
994        assert!(!m.is_match(after));
995        assert!(m.is_match(mid));
996        assert!(!m.is_match(before));
997    }
998
999    #[test]
1000    fn day_matcher_day_of_week_matches_any() {
1001        let monday = Date::from_calendar_date(2026, Month::January, 5).unwrap();
1002        assert_eq!(monday.weekday(), Weekday::Monday);
1003        let m = DayMatcher::day_of_week_any(Arc::from([Weekday::Sunday, Weekday::Monday]));
1004        assert!(m.is_match(monday));
1005    }
1006
1007    #[test]
1008    fn day_picker_month_grid_fixed_weeks_is_42_days() {
1009        let m = CalendarMonth::new(2026, Month::January);
1010        let grid = day_picker_month_grid(
1011            m,
1012            DayPickerGridOptions {
1013                week_start: Weekday::Monday,
1014                layout: DayPickerGridLayout::FixedWeeks,
1015            },
1016        );
1017        assert_eq!(grid.len(), 42);
1018    }
1019}