euv_ui/component/time_picker/view/fn.rs
1use super::*;
2
3/// The glyph drawn on the trailing edge of the trigger.
4const CARET_GLYPH: &str = "\u{25be}";
5
6/// The label of the button that selects the current time.
7const NOW_LABEL: &str = "Now";
8
9/// The label of the button that empties the field.
10const CLEAR_LABEL: &str = "Clear";
11
12/// The zero-padded labels of the hours and of the minutes.
13///
14/// The labels never change, so they are written out once instead of being
15/// formatted again for every cell of every render.
16const TIME_LABELS: [&str; 60] = [
17 "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15",
18 "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31",
19 "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47",
20 "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59",
21];
22
23/// Parses a `HH:MM` time.
24///
25/// A text that does not have that shape, or that names an hour or a minute
26/// outside the day, is rejected rather than silently repaired.
27///
28/// # Arguments
29///
30/// - `&str` - The text to parse.
31///
32/// # Returns
33///
34/// - `Option<(u32, u32)>` - The hour and the minute, or `None` when the text
35/// does not name a valid time.
36pub fn parse_time(text: &str) -> Option<(u32, u32)> {
37 let trimmed: &str = text.trim();
38 if trimmed.is_empty() {
39 return None;
40 }
41 let mut parts = trimmed.split(':');
42 let hour_text: &str = parts.next()?;
43 let minute_text: &str = parts.next()?;
44 if parts.next().is_some() {
45 return None;
46 }
47 let hour: u32 = parse_clock_part(hour_text)?;
48 let minute: u32 = parse_clock_part(minute_text)?;
49 if hour > 23 || minute > 59 {
50 return None;
51 }
52 Some((hour, minute))
53}
54
55/// Parses one part of a clock time.
56///
57/// A part is one or two decimal digits; anything else — a sign, a letter, a
58/// longer number — is refused.
59///
60/// # Arguments
61///
62/// - `&str` - The text of the part.
63///
64/// # Returns
65///
66/// - `Option<u32>` - The number the part names, or `None` when the text is
67/// not a clock part.
68fn parse_clock_part(text: &str) -> Option<u32> {
69 if text.is_empty() || text.len() > 2 {
70 return None;
71 }
72 if !text.bytes().all(|byte: u8| byte.is_ascii_digit()) {
73 return None;
74 }
75 text.parse::<u32>().ok()
76}
77
78/// Formats an hour and a minute as `HH:MM`.
79///
80/// # Arguments
81///
82/// - `u32` - The hour of the day, `0` through `23`.
83///
84/// # Returns
85///
86/// - `String` - The formatted time.
87pub fn format_time(hour: u32, minute: u32) -> String {
88 format!("{hour:02}:{minute:02}")
89}
90
91/// Clamps the hour bounds into a range the picker can offer.
92///
93/// Both bounds are clamped into the day, and a range whose bounds arrive in
94/// the wrong order is flipped, so the picker always offers at least one hour.
95///
96/// # Arguments
97///
98/// - `u32` - The first hour the caller asked for.
99///
100/// # Returns
101///
102/// - `(u32, u32)` - The first and the last hour of the offered range.
103pub fn clamp_hour_range(min_hour: u32, max_hour: u32) -> (u32, u32) {
104 let low: u32 = min_hour.min(23);
105 let high: u32 = max_hour.min(23);
106 if low <= high {
107 return (low, high);
108 }
109 (high, low)
110}
111
112/// Builds the hours the picker offers.
113///
114/// # Arguments
115///
116/// - `u32` - The first hour the caller asked for.
117///
118/// # Returns
119///
120/// - `Vec<u32>` - The offered hours, in display order.
121pub fn hour_values(min_hour: u32, max_hour: u32) -> Vec<u32> {
122 let (low, high) = clamp_hour_range(min_hour, max_hour);
123 (low..=high).collect()
124}
125
126/// Builds the minutes the picker offers.
127///
128/// The minutes run from zero to fifty-nine in steps of the given size, so a
129/// step of fifteen offers `0`, `15`, `30` and `45`. A step of zero is treated
130/// as a step of one minute.
131///
132/// # Arguments
133///
134/// - `u32` - The step between two offered minutes.
135///
136/// # Returns
137///
138/// - `Vec<u32>` - The offered minutes, in display order.
139pub fn minute_values(minute_step: u32) -> Vec<u32> {
140 let step: u32 = minute_step.max(1);
141 let mut minutes: Vec<u32> = Vec::with_capacity((60 / step) as usize + 1);
142 let mut minute: u32 = 0;
143 while minute < 60 {
144 minutes.push(minute);
145 minute = minute.saturating_add(step);
146 }
147 minutes
148}
149
150/// Returns the time that results from picking an hour.
151///
152/// The minutes of the current value are kept; a value that does not name a
153/// time falls back to zero minutes.
154///
155/// # Arguments
156///
157/// - `&str` - The current value, formatted as `HH:MM`.
158/// - `u32` - The picked hour.
159///
160/// # Returns
161///
162/// - `String` - The new time, formatted as `HH:MM`.
163pub fn time_with_hour(current: &str, hour: u32) -> String {
164 let minute: u32 = parse_time(current)
165 .map(|(_, minute): (u32, u32)| minute)
166 .unwrap_or(0);
167 format_time(hour, minute)
168}
169
170/// Returns the time that results from picking a minute.
171///
172/// The hour of the current value is kept; a value that does not name a time
173/// falls back to the given hour.
174///
175/// # Arguments
176///
177/// - `&str` - The current value, formatted as `HH:MM`.
178/// - `u32` - The picked minute.
179/// time.
180///
181/// # Returns
182///
183/// - `String` - The new time, formatted as `HH:MM`.
184pub fn time_with_minute(current: &str, minute: u32, fallback_hour: u32) -> String {
185 let hour: u32 = parse_time(current)
186 .map(|(hour, _): (u32, u32)| hour)
187 .unwrap_or(fallback_hour);
188 format_time(hour, minute)
189}
190
191/// Returns the current time of the host.
192///
193/// The browser's clock is read on the web. A host build has no clock the
194/// browser way, so it reports that no time is available and the "Now" button
195/// stays inert there.
196///
197/// # Returns
198///
199/// - `Option<(u32, u32)>` - The hour and the minute of the current time, or
200/// `None` when the host has no browser clock.
201pub fn current_time() -> Option<(u32, u32)> {
202 #[cfg(target_arch = "wasm32")]
203 {
204 let now: js_sys::Date = js_sys::Date::new_0();
205 Some((now.get_hours(), now.get_minutes()))
206 }
207 #[cfg(not(target_arch = "wasm32"))]
208 {
209 None
210 }
211}
212
213/// Builds the click handler that opens and closes the panel.
214///
215/// The event type is a parameter so the DOM-free test suite can drive the
216/// handler with a stand-in event; the component always instantiates it with
217/// [`Event`].
218///
219/// # Arguments
220///
221/// - `Signal<bool>` - The open state of the panel.
222///
223/// # Returns
224///
225/// - `Option<Rc<dyn Fn(E)>>` - The toggle click handler.
226pub fn time_picker_toggle_handler<E: 'static>(open: Signal<bool>) -> Option<Rc<dyn Fn(E)>> {
227 Some(Rc::new(move |_event: E| {
228 let is_open: bool = open.get();
229 open.set(!is_open);
230 }))
231}
232
233/// Builds the click handler that picks an hour.
234///
235/// The handler writes the new time into the selection signal, reports it to
236/// the change callback and closes the panel. The minutes of the current value
237/// are kept.
238///
239/// The event type is a parameter so the DOM-free test suite can drive the
240/// handler with a stand-in event; the component always instantiates it with
241/// [`Event`].
242///
243/// # Arguments
244///
245/// - `Signal<String>` - The signal holding the selected time.
246/// - `Signal<bool>` - The open state of the panel.
247/// - `Option<Rc<dyn Fn(&'static str)>>` - The callback receiving the time.
248/// - `u32` - The picked hour.
249///
250/// # Returns
251///
252/// - `Option<Rc<dyn Fn(E)>>` - The pick click handler.
253pub fn time_picker_pick_hour_handler<E: 'static>(
254 value: Signal<String>,
255 open: Signal<bool>,
256 on_change: Option<Rc<dyn Fn(&'static str)>>,
257 hour: u32,
258) -> Option<Rc<dyn Fn(E)>> {
259 Some(Rc::new(move |_event: E| {
260 let current: String = value.get();
261 let formatted: String = time_with_hour(¤t, hour);
262 let leaked: &'static str = Box::leak(formatted.clone().into_boxed_str());
263 value.set(formatted);
264 open.set(false);
265 if let Some(callback) = &on_change {
266 callback(leaked);
267 }
268 }))
269}
270
271/// Builds the click handler that picks a minute.
272///
273/// The handler writes the new time into the selection signal, reports it to
274/// the change callback and closes the panel. The hour of the current value is
275/// kept.
276///
277/// The event type is a parameter so the DOM-free test suite can drive the
278/// handler with a stand-in event; the component always instantiates it with
279/// [`Event`].
280///
281/// # Arguments
282///
283/// - `Signal<String>` - The signal holding the selected time.
284/// - `Signal<bool>` - The open state of the panel.
285/// - `Option<Rc<dyn Fn(&'static str)>>` - The callback receiving the time.
286/// - `u32` - The picked minute.
287/// time.
288///
289/// # Returns
290///
291/// - `Option<Rc<dyn Fn(E)>>` - The pick click handler.
292pub fn time_picker_pick_minute_handler<E: 'static>(
293 value: Signal<String>,
294 open: Signal<bool>,
295 on_change: Option<Rc<dyn Fn(&'static str)>>,
296 minute: u32,
297 fallback_hour: u32,
298) -> Option<Rc<dyn Fn(E)>> {
299 Some(Rc::new(move |_event: E| {
300 let current: String = value.get();
301 let formatted: String = time_with_minute(¤t, minute, fallback_hour);
302 let leaked: &'static str = Box::leak(formatted.clone().into_boxed_str());
303 value.set(formatted);
304 open.set(false);
305 if let Some(callback) = &on_change {
306 callback(leaked);
307 }
308 }))
309}
310
311/// Builds the click handler of the "Now" button.
312///
313/// The handler selects the current time and closes the panel. A host build
314/// has no browser clock, so the handler does nothing there.
315///
316/// The event type is a parameter so the DOM-free test suite can drive the
317/// handler with a stand-in event; the component always instantiates it with
318/// [`Event`].
319///
320/// # Arguments
321///
322/// - `Signal<String>` - The signal holding the selected time.
323/// - `Signal<bool>` - The open state of the panel.
324/// - `Option<Rc<dyn Fn(&'static str)>>` - The callback receiving the time.
325///
326/// # Returns
327///
328/// - `Rc<dyn Fn(E)>` - The click handler of the button.
329pub fn time_picker_now_handler<E: 'static>(
330 value: Signal<String>,
331 open: Signal<bool>,
332 on_change: Option<Rc<dyn Fn(&'static str)>>,
333) -> Rc<dyn Fn(E)> {
334 Rc::new(move |_event: E| {
335 let Some((hour, minute)) = current_time() else {
336 return;
337 };
338 let formatted: String = format_time(hour, minute);
339 let leaked: &'static str = Box::leak(formatted.clone().into_boxed_str());
340 value.set(formatted);
341 open.set(false);
342 if let Some(callback) = &on_change {
343 callback(leaked);
344 }
345 })
346}
347
348/// Builds the click handler of the "Clear" button.
349///
350/// The handler empties the field and closes the panel.
351///
352/// The event type is a parameter so the DOM-free test suite can drive the
353/// handler with a stand-in event; the component always instantiates it with
354/// [`Event`].
355///
356/// # Arguments
357///
358/// - `Signal<String>` - The signal holding the selected time.
359/// - `Signal<bool>` - The open state of the panel.
360/// - `Option<Rc<dyn Fn(&'static str)>>` - The callback receiving the time.
361///
362/// # Returns
363///
364/// - `Rc<dyn Fn(E)>` - The click handler of the button.
365pub fn time_picker_clear_handler<E: 'static>(
366 value: Signal<String>,
367 open: Signal<bool>,
368 on_change: Option<Rc<dyn Fn(&'static str)>>,
369) -> Rc<dyn Fn(E)> {
370 Rc::new(move |_event: E| {
371 value.set(String::new());
372 open.set(false);
373 if let Some(callback) = &on_change {
374 callback("");
375 }
376 })
377}
378
379/// Renders one choice cell of a column.
380///
381/// The chosen cell carries the `c_euv_time_picker_cell_active` modifier on
382/// top of the base `c_euv_time_picker_cell` class, the way the calendar
383/// stacks its `_today` and `_selected` modifiers on a day cell.
384///
385/// The label is written as a borrowed text node, so a cell costs no
386/// allocation for its own text.
387///
388/// # Arguments
389///
390/// - `&'static str` - The label of the cell.
391/// - `bool` - Whether the cell is the chosen one.
392/// - `Option<Rc<dyn Fn(Event)>>` - The click handler of the cell.
393///
394/// # Returns
395///
396/// - `VirtualNode` - The cell button.
397fn time_picker_cell(
398 label: &'static str,
399 is_active: bool,
400 handler: Option<Rc<dyn Fn(Event)>>,
401) -> VirtualNode {
402 let cell_text: VirtualNode = VirtualNode::Text(TextNode::new(Cow::Borrowed(label), None));
403 html! {
404 button {
405 type: "button"
406 class: c_euv_time_picker_cell()
407 class: if is_active {
408 c_euv_time_picker_cell_active()
409 } else {
410 c_euv_time_picker_cell()
411 }
412 onclick: handler
413 cell_text
414 }
415 }
416}
417
418/// A time-of-day picker with an always-mounted panel.
419///
420/// The component renders a `c_euv_time_picker` wrapper holding a
421/// `c_euv_time_picker_trigger` button and the panel. The trigger shows the
422/// selected time, or the placeholder while nothing is selected, and opens the
423/// panel; the panel carries a grid of hours and minutes and a footer with a
424/// "Now" and a "Clear" button.
425///
426/// The panel is always mounted: opening and closing it is a reactive class
427/// swap between `c_euv_time_picker_panel_open` and
428/// `c_euv_time_picker_panel_closed`, so the cells never have to be rebuilt
429/// while the user looks at them.
430///
431/// The selected time lives in a caller-owned [`Signal`] formatted as `HH:MM`,
432/// so the picker takes part in two-way binding. Picking an hour keeps the
433/// minutes of the current value and picking a minute keeps its hour; either
434/// way the new time is written into the signal, reported to the change
435/// callback and the panel closes.
436///
437/// # Arguments
438///
439/// - `VirtualNode<EuvTimePickerProps>` - The props node carrying the selected
440/// time, the offered hours, the minute step and the change callback.
441///
442/// # Returns
443///
444/// - `VirtualNode` - The time field with its hour and minute panel.
445#[component]
446pub fn euv_time_picker(node: VirtualNode<EuvTimePickerProps>) -> VirtualNode {
447 let EuvTimePickerProps {
448 value,
449 placeholder,
450 label: label_text,
451 min_hour,
452 max_hour,
453 minute_step,
454 on_change,
455 }: EuvTimePickerProps = node.try_get_props().unwrap_or_default();
456 let open: Signal<bool> = HookContext::use_hook(|| Signal::create(false));
457 let selected_value: String = value.get();
458 let has_value: bool = !selected_value.is_empty();
459 let selected: Option<(u32, u32)> = parse_time(&selected_value);
460 let trigger_text: String = if has_value {
461 selected_value
462 } else {
463 String::from(placeholder)
464 };
465 let first_hour: u32 = clamp_hour_range(min_hour, max_hour).0;
466 let hours: Vec<u32> = hour_values(min_hour, max_hour);
467 let minutes: Vec<u32> = minute_values(minute_step);
468 let selected_hour: Option<u32> = selected.map(|(hour, _): (u32, u32)| hour);
469 let selected_minute: Option<u32> = selected.map(|(_, minute): (u32, u32)| minute);
470 let hour_cells: Vec<VirtualNode> = hours
471 .iter()
472 .map(|hour: &u32| {
473 time_picker_cell(
474 TIME_LABELS[*hour as usize],
475 selected_hour == Some(*hour),
476 time_picker_pick_hour_handler(value, open, on_change.clone(), *hour),
477 )
478 })
479 .collect();
480 let minute_cells: Vec<VirtualNode> = minutes
481 .iter()
482 .map(|minute: &u32| {
483 time_picker_cell(
484 TIME_LABELS[*minute as usize],
485 selected_minute == Some(*minute),
486 time_picker_pick_minute_handler(
487 value,
488 open,
489 on_change.clone(),
490 *minute,
491 first_hour,
492 ),
493 )
494 })
495 .collect();
496 html! {
497 div {
498 class: c_euv_time_picker()
499 if !label_text.is_empty() {
500 label {
501 class: c_form_label()
502 label_text
503 }
504 }
505 button {
506 type: "button"
507 class: c_euv_time_picker_trigger()
508 onclick: time_picker_toggle_handler(open)
509 span {
510 class: if has_value {
511 c_euv_time_picker_value()
512 } else {
513 c_euv_time_picker_placeholder()
514 }
515 trigger_text
516 }
517 span {
518 class: c_euv_time_picker_caret()
519 CARET_GLYPH
520 }
521 }
522 div {
523 class: c_euv_time_picker_panel()
524 class: if { open } {
525 c_euv_time_picker_panel_open()
526 } else {
527 c_euv_time_picker_panel_closed()
528 }
529 div {
530 class: c_euv_time_picker_grid()
531 div {
532 class: c_euv_time_picker_column()
533 hour_cells
534 }
535 div {
536 class: c_euv_time_picker_column()
537 minute_cells
538 }
539 }
540 div {
541 class: c_euv_time_picker_footer()
542 euv_button {
543 variant: EuvButtonVariant::Outline
544 label: NOW_LABEL
545 onclick: Some(time_picker_now_handler(value, open, on_change.clone()))
546 }
547 euv_button {
548 variant: EuvButtonVariant::Outline
549 label: CLEAR_LABEL
550 onclick: Some(time_picker_clear_handler(value, open, on_change.clone()))
551 }
552 }
553 }
554 }
555 }
556}