Skip to main content

fc_dioxus/components/
datepicker.rs

1//! A month grid for browsing and choosing dates, and a segmented time entry, from
2//! datepicker.css. The grid ports upstream's `datepicker.ts`.
3//!
4//! The calendar is for browsing. Anyone who knows the date types it faster into a native
5//! `<input type="date">`, which is also the path with no script, so offer that input and
6//! open the calendar beside it rather than replacing it.
7use crate::{
8    behavior::date::{days_in_month, key_target, leading_blanks, Date},
9    core::{
10        assets,
11        attrs::{
12            component_attributes, date_attribute, date_value_attribute, datepicker_nav_attribute,
13        },
14        platform::DomBridge,
15    },
16    prelude::*,
17    theme::use_stylesheets,
18};
19const BEM: Bem = Bem::new("fc-datepicker");
20const CHEVRON: Bem = Bem::new("fc-datepicker__chevron");
21const BTN: Bem = Bem::new("fc-btn");
22const TIME: Bem = Bem::new("fc-timepicker");
23const INPUT: Bem = Bem::new("fc-input");
24const FIELD_LABEL: Bem = Bem::new("fc-field__label");
25fc_modifier! { enum ChevronSide for CHEVRON { Plain => None, Prev => "prev", Next => "next" } }
26
27/// The words a calendar shows. Rust has no locale data, so names come from the app; the
28/// default is English.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DateNames {
31    /// January to December.
32    pub months: [String; 12],
33    /// Sunday to Saturday, in full: column abbreviations and day names.
34    pub weekdays: [String; 7],
35    /// Sunday to Saturday, as drawn in the column headers.
36    pub narrow: [String; 7],
37    /// Names the previous-month button.
38    pub previous: String,
39    /// Names the next-month button.
40    pub next: String,
41}
42impl Default for DateNames {
43    fn default() -> Self {
44        let owned = |names: &[&str]| names.iter().map(|n| (*n).to_owned()).collect::<Vec<_>>();
45        let months = owned(&[
46            "January",
47            "February",
48            "March",
49            "April",
50            "May",
51            "June",
52            "July",
53            "August",
54            "September",
55            "October",
56            "November",
57            "December",
58        ]);
59        let weekdays = owned(&[
60            "Sunday",
61            "Monday",
62            "Tuesday",
63            "Wednesday",
64            "Thursday",
65            "Friday",
66            "Saturday",
67        ]);
68        Self {
69            months: std::array::from_fn(|i| months.get(i).cloned().unwrap_or_default()),
70            narrow: std::array::from_fn(|i| {
71                weekdays
72                    .get(i)
73                    .and_then(|w| w.get(..1))
74                    .unwrap_or_default()
75                    .to_owned()
76            }),
77            weekdays: std::array::from_fn(|i| weekdays.get(i).cloned().unwrap_or_default()),
78            previous: "Previous month".to_owned(),
79            next: "Next month".to_owned(),
80        }
81    }
82}
83impl DateNames {
84    fn month(&self, date: Date) -> &str {
85        self.months
86            .get(usize::from(date.month()) - 1)
87            .map_or("", String::as_str)
88    }
89    fn weekday(&self, index: u8) -> &str {
90        self.weekdays
91            .get(usize::from(index % 7))
92            .map_or("", String::as_str)
93    }
94    /// "Wednesday, October 21, 2026": a day button's accessible name.
95    fn full(&self, date: Date) -> String {
96        format!(
97            "{}, {} {}, {}",
98            self.weekday(date.weekday()),
99            self.month(date),
100            date.day(),
101            date.year()
102        )
103    }
104}
105
106/// A month grid on the WAI-ARIA date grid pattern: one tab stop, arrows by day (swapped
107/// in RTL) and week, Home and End to the week's ends, Page Up and Down by month (Shift
108/// for a year), Enter or Space to choose. Unavailable days stay focusable so they can be
109/// discovered, but cannot be chosen.
110#[component]
111pub fn DatePicker(
112    label: AccessibleName,
113    #[props(default)] value: Option<ReadSignal<Option<Date>>>,
114    #[props(default)] default_value: Option<Date>,
115    #[props(default)] onchange: Option<EventHandler<Date>>,
116    #[props(default)] min: Option<Date>,
117    #[props(default)] max: Option<Date>,
118    /// Today, marked `aria-current="date"`. The library reads no clock, so pass it in.
119    #[props(default)]
120    today: Option<Date>,
121    /// Further unavailable days, e.g. weekends.
122    #[props(default)]
123    unavailable: Option<Callback<Date, bool>>,
124    /// 0 = Sunday … 6 = Saturday.
125    #[props(default)]
126    first_day_of_week: u8,
127    #[props(default)] names: DateNames,
128    #[props(default)] class: Option<String>,
129    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
130) -> Element {
131    let state = use_controllable(
132        value,
133        || default_value,
134        onchange.map(|onchange| {
135            EventHandler::new(move |date: Option<Date>| {
136                if let Some(date) = date {
137                    onchange.call(date);
138                }
139            })
140        }),
141    );
142    let selected = state.get();
143    let clamp = move |date: Date| match (min, max) {
144        (Some(min), _) if date < min => min,
145        (_, Some(max)) if date > max => max,
146        _ => date,
147    };
148    let fallback = today.or(min).or(Date::new(2000, 1, 1).ok());
149    let mut focus = use_signal(|| selected.or(fallback).map(clamp));
150    // A selection made outside the calendar (a controlled value, a typed date) shows its
151    // month; the calendar's own navigation leaves `selected` alone, so it is undisturbed.
152    let mut seen = use_signal(|| selected);
153    if *seen.peek() != selected {
154        seen.set(selected);
155        if let Some(date) = selected {
156            focus.set(Some(clamp(date)));
157        }
158    }
159    let mut pending = use_signal(|| false);
160    let id = use_id();
161    let rtl = use_direction() == Direction::Rtl;
162    let blocked = move |date: Date| {
163        min.is_some_and(|m| date < m)
164            || max.is_some_and(|m| date > m)
165            || unavailable.is_some_and(|u| u.call(date))
166    };
167    let Some(shown) = focus() else {
168        return rsx! {};
169    };
170    // After a key moves focus into another month the grid re-renders; focus the new day
171    // once it exists.
172    let effect_id = id.clone();
173    use_effect(move || {
174        if pending() {
175            pending.set(false);
176            if let Ok(day) = Id::try_from(format!("{effect_id}-day")) {
177                spawn(async move {
178                    let _ = DomBridge.focus(&day).await;
179                });
180            }
181        }
182    });
183    let first = shown.first_of_month();
184    let blanks = leading_blanks(first, first_day_of_week);
185    let days = days_in_month(first.year(), first.month());
186    let prev_disabled = min.is_some_and(|m| first.add_days(-1).is_some_and(|d| d < m));
187    let next_disabled = max.is_some_and(|m| first.add_months(1).is_some_and(|d| d > m));
188    let title_id = id.suffixed("title");
189    let mut attributes = component_attributes(
190        attributes,
191        ClassList::new(BEM.block()).extend(class.as_deref()),
192        &["aria-label"],
193    );
194    attributes.extend(date_value_attribute(selected.map(|d| d.to_string())));
195    let nav_class = ClassList::new(BTN.block())
196        .with(BTN.modifier_raw("ghost"))
197        .with(BTN.modifier_raw("icon"))
198        .with(BTN.modifier_raw("sm"))
199        .to_string();
200    let mut page = move |delta: i32| {
201        if let Some(date) = focus().and_then(|d| d.add_months(delta)) {
202            focus.set(Some(clamp(date)));
203        }
204    };
205    let cells: Vec<Option<Date>> = (0..blanks)
206        .map(|_| None)
207        .chain((1..=days).map(|d| Date::new(first.year(), first.month(), d).ok()))
208        .collect();
209    let rows: Vec<Vec<Option<Date>>> = cells
210        .chunks(7)
211        .map(|row| {
212            let mut row = row.to_vec();
213            row.resize(7, None);
214            row
215        })
216        .collect();
217    let names_for_cells = names.clone();
218    rsx! {
219        if use_stylesheets() {
220            document::Stylesheet { href: assets::COMPONENTS_BUTTON_CSS }
221            document::Stylesheet { href: assets::COMPONENTS_DATEPICKER_CSS }
222        }
223        div { aria_label: label.to_string(), ..attributes,
224            div { class: BEM.element("header"),
225                button { class: nav_class.clone(), r#type: "button", aria_label: "{names.previous}",
226                    aria_disabled: prev_disabled.then_some("true"),
227                    onclick: move |_| if !prev_disabled { page(-1); },
228                    ..crate::spread![datepicker_nav_attribute(false)],
229                    span { class: ClassList::new(CHEVRON.block()).with_modifier(CHEVRON, ChevronSide::Prev).to_string(), aria_hidden: "true" }
230                }
231                // Announces the new month when the buttons or Page keys change it.
232                h2 { class: BEM.element("title"), id: "{title_id}", aria_live: "polite", "{names.month(first)} {first.year()}" }
233                button { class: nav_class, r#type: "button", aria_label: "{names.next}",
234                    aria_disabled: next_disabled.then_some("true"),
235                    onclick: move |_| if !next_disabled { page(1); },
236                    ..crate::spread![datepicker_nav_attribute(true)],
237                    span { class: ClassList::new(CHEVRON.block()).with_modifier(CHEVRON, ChevronSide::Next).to_string(), aria_hidden: "true" }
238                }
239            }
240            table { class: BEM.element("grid"), role: "grid", aria_labelledby: "{title_id}",
241                thead { tr {
242                    for column in 0..7_u8 {
243                        th { key: "{column}", class: BEM.element("weekday"), scope: "col",
244                            abbr: "{names.weekday(column + first_day_of_week)}",
245                            {names.narrow.get(usize::from((column + first_day_of_week) % 7)).cloned().unwrap_or_default()}
246                        }
247                    }
248                } }
249                tbody {
250                    for (index, row) in rows.into_iter().enumerate() {
251                        tr { key: "{index}",
252                            for (column, cell) in row.into_iter().enumerate() {
253                                match cell {
254                                    None => rsx! { td { key: "{column}", class: BEM.element("cell") } },
255                                    Some(date) => {
256                                        let is_focus = date == shown;
257                                        let disabled = blocked(date);
258                                        let iso = date.to_string();
259                                        let label = names_for_cells.full(date);
260                                        rsx! {
261                                            td { key: "{column}", class: BEM.element("cell"), role: "gridcell",
262                                                aria_selected: (selected == Some(date)).to_string(),
263                                                button { class: BEM.element("day"), r#type: "button",
264                                                    id: if is_focus { format!("{id}-day") } else { String::new() },
265                                                    tabindex: if is_focus { "0" } else { "-1" },
266                                                    aria_label: "{label}",
267                                                    aria_current: (today == Some(date)).then_some("date"),
268                                                    aria_disabled: disabled.then_some("true"),
269                                                    onclick: move |_| if !disabled { focus.set(Some(date)); state.set(Some(date)); },
270                                                    onkeydown: move |event| {
271                                                        if let Some(next) = key_target(&event.key(), event.modifiers().shift(), rtl, date, first_day_of_week) {
272                                                            event.prevent_default();
273                                                            focus.set(Some(clamp(next)));
274                                                            pending.set(true);
275                                                        }
276                                                    },
277                                                    ..crate::spread![date_attribute(&iso)],
278                                                    "{date.day()}"
279                                                }
280                                            }
281                                        }
282                                    }
283                                }
284                            }
285                        }
286                    }
287                }
288            }
289        }
290    }
291}
292
293/// A time of day.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
295pub struct Time {
296    /// 0–23.
297    pub hour: u8,
298    /// 0–59.
299    pub minute: u8,
300}
301
302/// Hour and minute fields, with an AM/PM choice in twelve-hour mode. Each field takes
303/// digits only and clamps to its range when it loses focus.
304#[component]
305pub fn TimePicker(
306    legend: Element,
307    #[props(default)] value: Option<ReadSignal<Time>>,
308    #[props(default = Time { hour: 9, minute: 0 })] default_value: Time,
309    #[props(default)] onchange: Option<EventHandler<Time>>,
310    #[props(default)] twelve_hour: bool,
311    #[props(default)] class: Option<String>,
312    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
313) -> Element {
314    let state = use_controllable(value, || default_value, onchange);
315    let time = state.get();
316    let period_name = use_id();
317    let attributes = component_attributes(
318        attributes,
319        ClassList::new(TIME.block()).extend(class.as_deref()),
320        &[],
321    );
322    let unit_class = ClassList::new(INPUT.block())
323        .with(TIME.element("unit"))
324        .to_string();
325    let shown_hour = if twelve_hour {
326        (time.hour + 11) % 12 + 1
327    } else {
328        time.hour
329    };
330    let pm = time.hour >= 12;
331    let set_hour = move |text: String| {
332        let Ok(typed) = text.parse::<u8>() else {
333            return;
334        };
335        let hour = if twelve_hour {
336            let base = typed.clamp(1, 12) % 12;
337            if state.get().hour >= 12 {
338                base + 12
339            } else {
340                base
341            }
342        } else {
343            typed.min(23)
344        };
345        state.set(Time {
346            hour,
347            ..state.get()
348        });
349    };
350    rsx! {
351        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_DATEPICKER_CSS } }
352        fieldset { ..attributes,
353            legend { class: FIELD_LABEL.block(), {legend} }
354            input { class: unit_class.clone(), inputmode: "numeric", aria_label: "Hour", maxlength: "2",
355                value: "{shown_hour:02}", onchange: move |event| set_hour(event.value()) }
356            span { class: TIME.element("separator"), aria_hidden: "true", ":" }
357            input { class: unit_class, inputmode: "numeric", aria_label: "Minute", maxlength: "2",
358                value: "{time.minute:02}",
359                onchange: move |event| if let Ok(minute) = event.value().parse::<u8>() { state.set(Time { minute: minute.min(59), ..state.get() }); } }
360            if twelve_hour {
361                Segmented { legend: crate::name!("Period"),
362                    segments: Segments::Single { name: period_name, value: Some(if pm { "pm" } else { "am" }.to_owned()),
363                        onchange: Some(EventHandler::new(move |period: String| {
364                            let now = state.get();
365                            let hour = match (period.as_str(), now.hour >= 12) {
366                                ("pm", false) => now.hour + 12,
367                                ("am", true) => now.hour - 12,
368                                _ => now.hour,
369                            };
370                            state.set(Time { hour, ..now });
371                        })) },
372                    Segment { value: "am", "AM" }
373                    Segment { value: "pm", "PM" }
374                }
375            }
376        }
377    }
378}