1use 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DateNames {
31 pub months: [String; 12],
33 pub weekdays: [String; 7],
35 pub narrow: [String; 7],
37 pub previous: String,
39 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 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#[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 #[props(default)]
120 today: Option<Date>,
121 #[props(default)]
123 unavailable: Option<Callback<Date, bool>>,
124 #[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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
295pub struct Time {
296 pub hour: u8,
298 pub minute: u8,
300}
301
302#[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}