Skip to main content

orbital_base_components/form/time_picker/
mod.rs

1use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike};
2use leptos::{children::ToChildren, ev, prelude::*};
3
4use crate::{
5    form::{DatetimeFormat, FieldInjection, OptionBind},
6    overlay::{positioning::AnchorWidth, AnchoredPanel, AnchoredPositioner, Placement},
7};
8
9pub fn format_time_value(unix_secs: i64, format: DatetimeFormat) -> String {
10    local_from_unix(unix_secs)
11        .map(|dt| dt.format(pattern_for(format)).to_string())
12        .unwrap_or_default()
13}
14
15pub fn parse_time_input(input: &str, format: DatetimeFormat) -> Option<NaiveTime> {
16    NaiveTime::parse_from_str(input.trim(), pattern_for(format)).ok()
17}
18
19pub fn to_panel_time(unix_secs: i64) -> Option<NaiveTime> {
20    local_from_unix(unix_secs).map(|dt| dt.time())
21}
22
23pub fn now_time() -> NaiveTime {
24    Local::now().time()
25}
26
27pub fn normalize_reference_date(reference_date: Option<i64>) -> NaiveDate {
28    reference_date
29        .and_then(date_from_unix)
30        .unwrap_or_else(|| Local::now().date_naive())
31}
32
33pub fn compose_time_unix(
34    time: NaiveTime,
35    current_value: Option<i64>,
36    reference_date: Option<i64>,
37) -> Option<i64> {
38    let base_date = current_value
39        .and_then(date_from_unix)
40        .unwrap_or_else(|| normalize_reference_date(reference_date));
41    let naive = NaiveDateTime::new(base_date, time);
42    Local
43        .from_local_datetime(&naive)
44        .single()
45        .map(|dt| dt.timestamp())
46}
47
48fn local_from_unix(secs: i64) -> Option<DateTime<Local>> {
49    Local.timestamp_opt(secs, 0).single()
50}
51
52fn date_from_unix(secs: i64) -> Option<NaiveDate> {
53    local_from_unix(secs).map(|dt| dt.date_naive())
54}
55
56fn pattern_for(format: DatetimeFormat) -> &'static str {
57    match format {
58        DatetimeFormat::Time24 => "%H:%M:%S",
59        DatetimeFormat::Time12 => "%I:%M:%S %p",
60        DatetimeFormat::IsoDate => "%H:%M:%S",
61        DatetimeFormat::UsDate => "%H:%M:%S",
62    }
63}
64
65/// Headless time picker with anchored scroll columns.
66#[component]
67pub fn BaseTimePicker(
68    #[prop(optional, into)] class: MaybeProp<String>,
69    #[prop(optional, into)] id: MaybeProp<String>,
70    #[prop(optional, into)] name: MaybeProp<String>,
71    #[prop(optional, into)] value: OptionBind<i64>,
72    #[prop(optional, into, default = Signal::from(DatetimeFormat::Time12))] format: Signal<
73        DatetimeFormat,
74    >,
75    #[prop(optional, into)] reference_date: Signal<Option<i64>>,
76    #[prop(optional, into)] disabled: Signal<bool>,
77) -> impl IntoView {
78    let (id, name) = FieldInjection::use_id_and_name(id, name);
79    let format = StoredValue::new(normalize_time_format(format.get_untracked()));
80    let value = StoredValue::new(value);
81    let reference_date = StoredValue::new(reference_date);
82    let open = RwSignal::new(false);
83    let draft = RwSignal::new(
84        value
85            .get_value()
86            .get_untracked()
87            .and_then(to_panel_time)
88            .unwrap_or_else(now_time),
89    );
90
91    Effect::new(move |_| {
92        if open.get() {
93            return;
94        }
95        if let Some(secs) = value.get_value().get() {
96            if let Some(time) = to_panel_time(secs) {
97                draft.set(time);
98            }
99        }
100    });
101
102    let show_label = Signal::derive(move || {
103        value
104            .get_value()
105            .get()
106            .map(|secs| format_time_value(secs, format.get_value()))
107            .unwrap_or_else(|| "--:--:--".to_string())
108    });
109
110    let open_panel = move |_| {
111        if disabled.get_untracked() {
112            return;
113        }
114        let next = value
115            .get_value()
116            .get_untracked()
117            .and_then(to_panel_time)
118            .unwrap_or_else(now_time);
119        draft.set(next);
120        open.set(true);
121    };
122
123    let set_now = move |_| draft.set(now_time());
124    let commit = move |_| {
125        let Some(draft_time) = draft.try_get_untracked() else {
126            return;
127        };
128        if let Some(unix_secs) = compose_time_unix(
129            draft_time,
130            value.get_value().get_untracked(),
131            reference_date.get_value().get_untracked(),
132        ) {
133            value.get_value().set(Some(unix_secs));
134        }
135        open.set(false);
136    };
137
138    view! {
139        <AnchoredPositioner panel=AnchoredPanel {
140            show: open.read_only().into(),
141            width: Some(AnchorWidth::Target),
142            placement: Placement::BottomStart,
143            auto_height: false,
144            arrow: None,
145            motion: None,
146            children: ToChildren::to_children(move || {
147                let hour =
148                    Signal::derive(move || draft.try_get().map(|time| time.hour()).unwrap_or(0));
149                let minute =
150                    Signal::derive(move || draft.try_get().map(|time| time.minute()).unwrap_or(0));
151                let second =
152                    Signal::derive(move || draft.try_get().map(|time| time.second()).unwrap_or(0));
153                let is_12 = format.get_value() == DatetimeFormat::Time12;
154                let display_hour = Signal::derive(move || to_display_hour(hour.get(), is_12));
155                let period = Signal::derive(move || if hour.get() >= 12 { "PM" } else { "AM" });
156
157                view! {
158                    <div class="orbital-time-picker-panel" role="dialog" aria-label="Time picker">
159                        <div class="orbital-time-picker-panel__columns">
160                            <div class="orbital-time-picker-panel__column">
161                                <div class="orbital-time-picker-panel__scroll">
162                                    {move || {
163                                        if is_12 {
164                                            (1..=12)
165                                                .map(|h| {
166                                                    let selected = Signal::derive(move || display_hour.get() == h);
167                                                    view! {
168                                                        <button
169                                                            type="button"
170                                                            class="orbital-time-picker-panel__item"
171                                                            class=("orbital-time-picker-panel__item--selected", move || selected.get())
172                                                            on:click=move |_| {
173                                                                let Some(t) = draft.try_get_untracked() else {
174                                                                    return;
175                                                                };
176                                                                let minute = t.minute();
177                                                                let second = t.second();
178                                                                let next_h =
179                                                                    to_twenty_four_hour(h, t.hour() >= 12);
180                                                                if let Some(next) =
181                                                                    NaiveTime::from_hms_opt(next_h, minute, second)
182                                                                {
183                                                                    draft.set(next);
184                                                                }
185                                                            }
186                                                        >
187                                                            {format!("{h:02}")}
188                                                        </button>
189                                                    }
190                                                })
191                                                .collect_view()
192                                                .into_any()
193                                        } else {
194                                            (0..24)
195                                                .map(|h| {
196                                                    let selected = Signal::derive(move || hour.get() == h);
197                                                    view! {
198                                                        <button
199                                                            type="button"
200                                                            class="orbital-time-picker-panel__item"
201                                                            class=("orbital-time-picker-panel__item--selected", move || selected.get())
202                                                            on:click=move |_| {
203                                                                let Some(t) = draft.try_get_untracked() else {
204                                                                    return;
205                                                                };
206                                                                let minute = t.minute();
207                                                                let second = t.second();
208                                                                if let Some(next) =
209                                                                    NaiveTime::from_hms_opt(h, minute, second)
210                                                                {
211                                                                    draft.set(next);
212                                                                }
213                                                            }
214                                                        >
215                                                            {format!("{h:02}")}
216                                                        </button>
217                                                    }
218                                                })
219                                                .collect_view()
220                                                .into_any()
221                                        }
222                                    }}
223                                </div>
224                            </div>
225                            <div class="orbital-time-picker-panel__column">
226                                <div class="orbital-time-picker-panel__scroll">
227                                    {(0..60)
228                                        .map(|m| {
229                                            let selected = Signal::derive(move || minute.get() == m);
230                                            view! {
231                                                <button
232                                                    type="button"
233                                                    class="orbital-time-picker-panel__item"
234                                                    class=("orbital-time-picker-panel__item--selected", move || selected.get())
235                                                    on:click=move |_| {
236                                                        let Some(t) = draft.try_get_untracked() else {
237                                                            return;
238                                                        };
239                                                        let hour = t.hour();
240                                                        let second = t.second();
241                                                        if let Some(next) =
242                                                            NaiveTime::from_hms_opt(hour, m, second)
243                                                        {
244                                                            draft.set(next);
245                                                        }
246                                                    }
247                                                >
248                                                    {format!("{m:02}")}
249                                                </button>
250                                            }
251                                        })
252                                        .collect_view()}
253                                </div>
254                            </div>
255                            <div class="orbital-time-picker-panel__column">
256                                <div class="orbital-time-picker-panel__scroll">
257                                    {(0..60)
258                                        .map(|s| {
259                                            let selected = Signal::derive(move || second.get() == s);
260                                            view! {
261                                                <button
262                                                    type="button"
263                                                    class="orbital-time-picker-panel__item"
264                                                    class=("orbital-time-picker-panel__item--selected", move || selected.get())
265                                                    on:click=move |_| {
266                                                        let Some(t) = draft.try_get_untracked() else {
267                                                            return;
268                                                        };
269                                                        let hour = t.hour();
270                                                        let minute = t.minute();
271                                                        if let Some(next) =
272                                                            NaiveTime::from_hms_opt(hour, minute, s)
273                                                        {
274                                                            draft.set(next);
275                                                        }
276                                                    }
277                                                >
278                                                    {format!("{s:02}")}
279                                                </button>
280                                            }
281                                        })
282                                        .collect_view()}
283                                </div>
284                            </div>
285                            {if is_12 {
286                                Some(view! {
287                                    <div class="orbital-time-picker-panel__column orbital-time-picker-panel__column--period">
288                                        <div class="orbital-time-picker-panel__scroll">
289                                            <button
290                                                type="button"
291                                                class="orbital-time-picker-panel__item"
292                                                class=("orbital-time-picker-panel__item--selected", move || period.get() == "AM")
293                                                on:click=move |_| {
294                                                    let Some(t) = draft.try_get_untracked() else {
295                                                        return;
296                                                    };
297                                                    let h = t.hour();
298                                                    if h >= 12 {
299                                                        if let Some(next) = NaiveTime::from_hms_opt(
300                                                            h - 12,
301                                                            t.minute(),
302                                                            t.second(),
303                                                        ) {
304                                                            draft.set(next);
305                                                        }
306                                                    }
307                                                }
308                                            >
309                                                "AM"
310                                            </button>
311                                            <button
312                                                type="button"
313                                                class="orbital-time-picker-panel__item"
314                                                class=("orbital-time-picker-panel__item--selected", move || period.get() == "PM")
315                                                on:click=move |_| {
316                                                    let Some(t) = draft.try_get_untracked() else {
317                                                        return;
318                                                    };
319                                                    let h = t.hour();
320                                                    if h < 12 {
321                                                        if let Some(next) = NaiveTime::from_hms_opt(
322                                                            h + 12,
323                                                            t.minute(),
324                                                            t.second(),
325                                                        ) {
326                                                            draft.set(next);
327                                                        }
328                                                    }
329                                                }
330                                            >
331                                                "PM"
332                                            </button>
333                                        </div>
334                                    </div>
335                                })
336                            } else {
337                                None
338                            }}
339                        </div>
340                        <div class="orbital-time-picker-panel__actions">
341                            <button type="button" class="orbital-time-picker-panel__action" on:click=set_now>
342                                "Now"
343                            </button>
344                            <button type="button" class="orbital-time-picker-panel__action orbital-time-picker-panel__action--primary" on:click=commit>
345                                "OK"
346                            </button>
347                        </div>
348                    </div>
349                }
350            }),
351        }>
352            <div class=move || {
353                let mut parts = vec!["orbital-time-picker".to_string()];
354                if disabled.get() {
355                    parts.push("orbital-time-picker--disabled".to_string());
356                }
357                if let Some(extra) = class.get() {
358                    if !extra.is_empty() {
359                        parts.push(extra);
360                    }
361                }
362                parts.join(" ")
363            }>
364                <button
365                    id=id
366                    type="button"
367                    class="orbital-time-picker__trigger"
368                    aria-haspopup="dialog"
369                    aria-expanded=move || open.get().to_string()
370                    disabled=move || disabled.get().then_some("")
371                    on:click=open_panel
372                    on:keydown=move |ev: ev::KeyboardEvent| {
373                        if ev.key() == "Escape" {
374                            open.set(false);
375                        }
376                    }
377                >
378                    {move || show_label.get()}
379                </button>
380                <input
381                    type="hidden"
382                    name=move || name.get()
383                    value=move || value.get_value().get().map(|v| v.to_string()).unwrap_or_default()
384                />
385            </div>
386        </AnchoredPositioner>
387    }
388}
389
390fn normalize_time_format(format: DatetimeFormat) -> DatetimeFormat {
391    match format {
392        DatetimeFormat::Time24 | DatetimeFormat::Time12 => format,
393        _ => DatetimeFormat::Time12,
394    }
395}
396
397fn to_display_hour(hour: u32, is_12: bool) -> u32 {
398    if !is_12 {
399        return hour;
400    }
401    let hour = hour % 12;
402    if hour == 0 {
403        12
404    } else {
405        hour
406    }
407}
408
409fn to_twenty_four_hour(hour_12: u32, is_pm: bool) -> u32 {
410    match (hour_12 % 12, is_pm) {
411        (0, false) => 0,
412        (0, true) => 12,
413        (h, false) => h,
414        (h, true) => h + 12,
415    }
416}