Skip to main content

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(&current, 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(&current, 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}