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}