Skip to main content

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}