orbital_base_components/form/date_picker/base.rs
1use chrono::{Datelike, Months};
2use leptos::prelude::*;
3use std::sync::Arc;
4
5use crate::form::{
6 build_month_grid, is_day_disabled, DatetimeTimezone, OptionBind, OrbitalDateTime,
7 PickerShortcut, PickerShortcutsBar,
8};
9use crate::overlay::{AnchoredPanel, AnchoredPositioner, Placement};
10
11use super::month::month_short_name;
12use super::panel::{
13 date_from_unix, month_heading, start_of_day_unix, today_for_timezone, DatePickerPanelMode,
14};
15use super::year::{build_years, year_panel_start};
16
17const WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
18
19/// Props for a custom month button in [`BaseDatePicker`]'s month panel.
20#[derive(Clone)]
21pub struct MonthButtonRenderProps {
22 pub month: u32,
23 pub label: String,
24 pub selected: bool,
25 pub on_select: Callback<()>,
26}
27
28pub type MonthButtonRenderer = Arc<dyn Fn(MonthButtonRenderProps) -> AnyView + Send + Sync>;
29
30fn default_month_button(props: MonthButtonRenderProps) -> impl IntoView {
31 view! {
32 <button
33 type="button"
34 class="orbital-date-picker__month-button"
35 class=("orbital-date-picker__month-button--selected", props.selected)
36 on:click=move |_| props.on_select.run(())
37 >
38 {props.label}
39 </button>
40 }
41}
42
43/// Headless anchored date picker panel and trigger wrapper.
44#[component]
45pub fn BaseDatePicker(
46 #[prop(optional, into)] class: MaybeProp<String>,
47 #[prop(optional, into)] panel_class: MaybeProp<String>,
48 #[prop(optional, into)] value: OptionBind<i64>,
49 #[prop(into)] timezone: Signal<DatetimeTimezone>,
50 #[prop(optional, into)] min_date: Signal<Option<OrbitalDateTime>>,
51 #[prop(optional, into)] max_date: Signal<Option<OrbitalDateTime>>,
52 #[prop(optional, into)] shortcuts: Signal<Vec<PickerShortcut>>,
53 #[prop(optional, into)] disabled: Signal<bool>,
54 #[prop(optional, into)] placement: Signal<Placement>,
55 #[prop(optional)] on_select: Option<Callback<i64>>,
56 #[prop(optional)] on_shortcut: Option<Callback<OrbitalDateTime>>,
57 #[prop(default = None)] month_button: Option<MonthButtonRenderer>,
58 children: Children,
59) -> impl IntoView {
60 let value = StoredValue::new(value);
61 let is_open = RwSignal::new(false);
62 let panel_mode = RwSignal::new(DatePickerPanelMode::Date);
63 let month_button = StoredValue::new(month_button);
64 let focused_date = RwSignal::new(
65 value
66 .get_value()
67 .get_untracked()
68 .and_then(|secs| date_from_unix(secs, timezone.get_untracked()))
69 .unwrap_or_else(|| today_for_timezone(timezone.get_untracked())),
70 );
71
72 Effect::new(move |_| {
73 if let Some(selected) = value.get_value().get() {
74 if let Some(date) = date_from_unix(selected, timezone.get()) {
75 focused_date.set(date);
76 }
77 }
78 });
79
80 let panel_classes = Signal::derive(move || {
81 let mut parts = vec!["orbital-date-picker__panel".to_string()];
82 if let Some(extra) = panel_class.get() {
83 if !extra.is_empty() {
84 parts.push(extra);
85 }
86 }
87 parts.join(" ")
88 });
89
90 let open_panel = move |_| {
91 if disabled.get() {
92 return;
93 }
94 is_open.set(true);
95 };
96
97 view! {
98 <AnchoredPositioner panel=AnchoredPanel {
99 show: is_open.read_only().into(),
100 width: None,
101 placement: placement.get_untracked(),
102 auto_height: false,
103 arrow: None,
104 motion: None,
105 children: ToChildren::to_children(move || {
106 view! {
107 <div class=panel_classes data-testid="date-picker-panel">
108 <div class="orbital-date-picker__panel-header">
109 <button
110 type="button"
111 class="orbital-date-picker__nav-button"
112 on:click=move |_| {
113 match panel_mode.get() {
114 DatePickerPanelMode::Date => {
115 focused_date.update(|date| {
116 if let Some(previous) = date
117 .checked_sub_months(Months::new(1))
118 {
119 *date = previous;
120 }
121 });
122 }
123 DatePickerPanelMode::Month => {
124 focused_date
125 .update(|date| *date = *date - chrono::Days::new(365));
126 }
127 DatePickerPanelMode::Year => {
128 focused_date
129 .update(|date| *date = *date - chrono::Days::new(3650));
130 }
131 }
132 }
133 aria-label="Previous"
134 >
135 "Prev"
136 </button>
137 <button
138 type="button"
139 class="orbital-date-picker__title-button"
140 on:click=move |_| {
141 panel_mode.set(match panel_mode.get() {
142 DatePickerPanelMode::Date => DatePickerPanelMode::Month,
143 DatePickerPanelMode::Month => DatePickerPanelMode::Year,
144 DatePickerPanelMode::Year => DatePickerPanelMode::Date,
145 });
146 }
147 >
148 {move || {
149 let focused = focused_date.get();
150 match panel_mode.get() {
151 DatePickerPanelMode::Date => {
152 month_heading(focused.year(), focused.month())
153 }
154 DatePickerPanelMode::Month => focused.year().to_string(),
155 DatePickerPanelMode::Year => {
156 let start = year_panel_start(focused.year());
157 format!("{start} - {}", start + 11)
158 }
159 }
160 }}
161 </button>
162 <button
163 type="button"
164 class="orbital-date-picker__nav-button"
165 on:click=move |_| {
166 match panel_mode.get() {
167 DatePickerPanelMode::Date => {
168 focused_date.update(|date| {
169 if let Some(next) = date.checked_add_months(Months::new(1)) {
170 *date = next;
171 }
172 });
173 }
174 DatePickerPanelMode::Month => {
175 focused_date
176 .update(|date| *date = *date + chrono::Days::new(365));
177 }
178 DatePickerPanelMode::Year => {
179 focused_date
180 .update(|date| *date = *date + chrono::Days::new(3650));
181 }
182 }
183 }
184 aria-label="Next"
185 >
186 "Next"
187 </button>
188 </div>
189 <div class="orbital-date-picker__panel-body">
190 {move || {
191 let tz = timezone.get();
192 let focused = focused_date.get();
193 let selected = value
194 .get_value()
195 .get()
196 .and_then(|secs| date_from_unix(secs, tz));
197
198 match panel_mode.get() {
199 DatePickerPanelMode::Date => {
200 let today = today_for_timezone(tz);
201 let min = min_date.get();
202 let max = max_date.get();
203 let days = build_month_grid(focused.year(), focused.month());
204 view! {
205 <div class="orbital-date-picker__weekday-row">
206 {WEEKDAYS
207 .into_iter()
208 .map(|name| view! { <span>{name}</span> })
209 .collect_view()}
210 </div>
211 <div class="orbital-date-picker__date-grid">
212 {days
213 .into_iter()
214 .map(|day| {
215 let is_current_month =
216 day.kind == crate::form::GridDayKind::Current;
217 let is_selected = selected
218 .map(|selected_date| selected_date == day.date)
219 .unwrap_or(false);
220 let is_today = day.date == today;
221 let is_disabled = is_day_disabled(
222 day.date,
223 min,
224 max,
225 );
226 let on_click = {
227 let date = day.date;
228 let on_select = on_select;
229 move |_| {
230 if disabled.get_untracked()
231 || is_disabled
232 {
233 return;
234 }
235 focused_date.set(date);
236 if let Some(unix_secs) =
237 start_of_day_unix(date, tz)
238 {
239 value
240 .with_value(|v| v.set(Some(unix_secs)));
241 if let Some(on_select) = on_select
242 {
243 on_select.run(unix_secs);
244 }
245 }
246 is_open.set(false);
247 }
248 };
249 view! {
250 <button
251 type="button"
252 class="orbital-date-picker__day-button"
253 class=(
254 "orbital-date-picker__day-button--outside",
255 !is_current_month,
256 )
257 class=(
258 "orbital-date-picker__day-button--selected",
259 is_selected,
260 )
261 class=(
262 "orbital-date-picker__day-button--today",
263 is_today,
264 )
265 class=(
266 "orbital-date-picker__day-button--disabled",
267 is_disabled,
268 )
269 disabled=is_disabled
270 on:click=on_click
271 >
272 {day.date.day().to_string()}
273 </button>
274 }
275 })
276 .collect_view()}
277 </div>
278 {move || {
279 if shortcuts.get().is_empty() {
280 ().into_any()
281 } else {
282 let on_shortcut_cb = Callback::new(
283 move |dt: OrbitalDateTime| {
284 if disabled.get_untracked() {
285 return;
286 }
287 let tz = timezone.get_untracked();
288 if let Some(date) = dt.wall_date() {
289 focused_date.set(date);
290 if let Some(unix_secs) =
291 start_of_day_unix(date, tz)
292 {
293 value.with_value(|v| {
294 v.set(Some(unix_secs))
295 });
296 if let Some(on_select) =
297 on_select
298 {
299 on_select.run(unix_secs);
300 }
301 if let Some(on_shortcut) =
302 on_shortcut
303 {
304 on_shortcut.run(dt);
305 }
306 }
307 }
308 is_open.set(false);
309 },
310 );
311 view! {
312 <PickerShortcutsBar
313 shortcuts=shortcuts
314 disabled=disabled
315 on_select=on_shortcut_cb
316 />
317 }
318 .into_any()
319 }
320 }}
321 }
322 .into_any()
323 }
324 DatePickerPanelMode::Month => view! {
325 <div class="orbital-date-picker__month-grid">
326 {(1_u32..=12)
327 .map(|month| {
328 let is_selected = focused.month() == month;
329 let label = month_short_name(month).to_string();
330 let on_select = Callback::new(move |_| {
331 focused_date.update(|date| {
332 if let Some(next) = date.with_month(month) {
333 *date = next;
334 }
335 });
336 panel_mode.set(DatePickerPanelMode::Date);
337 });
338 let props = MonthButtonRenderProps {
339 month,
340 label,
341 selected: is_selected,
342 on_select,
343 };
344 if let Some(renderer) = month_button.get_value().clone() {
345 renderer(props).into_any()
346 } else {
347 default_month_button(props).into_any()
348 }
349 })
350 .collect_view()}
351 </div>
352 }
353 .into_any(),
354 DatePickerPanelMode::Year => {
355 let start = year_panel_start(focused.year());
356 let years = build_years(start);
357 view! {
358 <div class="orbital-date-picker__year-grid">
359 {years
360 .into_iter()
361 .map(|year| {
362 let is_selected = focused.year() == year;
363 view! {
364 <button
365 type="button"
366 class="orbital-date-picker__year-button"
367 class=(
368 "orbital-date-picker__year-button--selected",
369 is_selected,
370 )
371 on:click=move |_| {
372 focused_date.update(|date| {
373 if let Some(next) = date
374 .with_year(year)
375 {
376 *date = next;
377 }
378 });
379 panel_mode.set(DatePickerPanelMode::Month);
380 }
381 >
382 {year.to_string()}
383 </button>
384 }
385 })
386 .collect_view()}
387 </div>
388 }
389 .into_any()
390 }
391 }
392 }}
393 </div>
394 </div>
395 }
396 }),
397 }>
398 <div
399 class=move || {
400 let mut parts = vec!["orbital-date-picker".to_string()];
401 if let Some(extra) = class.get() {
402 if !extra.is_empty() {
403 parts.push(extra);
404 }
405 }
406 parts.join(" ")
407 }
408 on:click=open_panel
409 >
410 {children()}
411 </div>
412 </AnchoredPositioner>
413 }
414}