Skip to main content

orbital_date_pickers/building_blocks/digital_clock/
mod.rs

1//! [`DigitalClock`] — scrollable time list bound to [`OrbitalDateTime`].
2
3mod styles;
4
5use leptos::prelude::*;
6use orbital_base_components::{ListNavigationMode, ListSelectionMode};
7use orbital_core_components::{List, ListItem, ScrollArea};
8use orbital_macros::component_doc;
9use orbital_theme::use_theme_options;
10
11use crate::shared::{
12    commit_time, format_slot_label, generate_time_slots, picker_style_sheet, resolve_anchor,
13    use_datetime_locale,
14};
15
16use super::field_types::{DigitalClockAppearance, DigitalClockBind};
17use styles::digital_clock_styles;
18
19/// Scrollable time list for selecting time-of-day, bound to [`OrbitalDateTime`].
20///
21/// DigitalClock renders a vertical list of time slots at `appearance.time_step` increments.
22/// Values anchor to `appearance.reference_date` or the nearest [`DatetimeLocale`](crate::DatetimeLocale)
23/// default. Clock surfaces are optional product features documented via
24/// [`DatePickerFeatures::CLOCK_VIEWS`] — there is no runtime license check.
25///
26/// # When to use
27///
28/// - Compact time selection in picker panels
29/// - Alternatives to scroll-column [`TimePicker`](orbital_core_components::TimePicker) surfaces
30///
31/// # Usage
32///
33/// 1. Bind `Option<OrbitalDateTime>` via [`DigitalClockBind`].
34/// 2. Set `appearance.time_step` for list increment (default 30 minutes).
35/// 3. Wrap preview examples in a native element with `data-testid`.
36///
37/// # Best Practices
38///
39/// ## Do's
40///
41/// * Enable clock views in product docs with [`DatePickerFeatures::CLOCK_VIEWS`]
42/// * Use 15- or 30-minute steps for scheduling UIs
43///
44/// ## Don'ts
45///
46/// * Do not put `data-testid` on the component — wrap with a native element
47///
48/// # Examples
49///
50/// ## Digital list
51/// Scroll a 30-minute slot list; the readout above shows your selection (preview also prints unix for E2E).
52/// <!-- preview -->
53/// ```rust
54/// use orbital_base_components::{DatetimeFormat, OrbitalDateTime, ToUnixSeconds, format_datetime};
55/// use crate::preview::{PickerPreviewExample, PickerPreviewKnobs};
56/// let value = RwSignal::new(None::<OrbitalDateTime>);
57/// view! {
58///     <PickerPreviewExample data_testid="digital-clock-preview">
59///         <PickerPreviewKnobs />
60///         <DigitalClock bind=value />
61///         <div data-testid="digital-clock-preview-VALUE">{move || value.get().map(|v| v.to_unix_seconds().to_string()).unwrap_or_else(|| "none".to_string())}</div>
62///         <div data-testid="digital-clock-preview-LABEL">{move || value.get().map(|v| format_datetime(v, DatetimeFormat::Time12)).unwrap_or_else(|| "none".to_string())}</div>
63///     </PickerPreviewExample>
64/// }
65/// ```
66///
67/// ## List selection
68/// Click a time slot to commit the bound value.
69/// <!-- preview -->
70/// ```rust
71/// use orbital_base_components::{DatetimeFormat, OrbitalDateTime, ToUnixSeconds, format_datetime};
72/// use crate::preview::PickerPreviewExample;
73/// let value = RwSignal::new(None::<OrbitalDateTime>);
74/// view! {
75///     <PickerPreviewExample data_testid="DC-02">
76///         <DigitalClock bind=value />
77///         <div data-testid="DC-02-VALUE">{move || value.get().map(|v| v.to_unix_seconds().to_string()).unwrap_or_else(|| "none".to_string())}</div>
78///         <div data-testid="DC-02-LABEL">{move || value.get().map(|v| format_datetime(v, DatetimeFormat::Time12)).unwrap_or_else(|| "none".to_string())}</div>
79///     </PickerPreviewExample>
80/// }
81/// ```
82///
83/// ## Fifteen-minute steps
84/// List items appear every 15 minutes.
85/// <!-- preview -->
86/// ```rust
87/// use leptos::prelude::*;
88/// use orbital_base_components::OrbitalDateTime;
89/// use crate::preview::PickerPreviewExample;
90/// let value = RwSignal::new(None::<OrbitalDateTime>);
91/// view! {
92///     <PickerPreviewExample data_testid="DC-03">
93///         <DigitalClock
94///             bind=value
95///             appearance=DigitalClockAppearance {
96///                 time_step: Signal::from(15),
97///                 ..Default::default()
98///             }
99///         />
100///     </PickerPreviewExample>
101/// }
102/// ```
103///
104/// ## 24-hour labels
105/// Slots display 24-hour formatted labels.
106/// <!-- preview -->
107/// ```rust
108/// use orbital_base_components::OrbitalDateTime;
109/// use crate::preview::PickerPreviewExample;
110/// let value = RwSignal::new(None::<OrbitalDateTime>);
111/// view! {
112///     <PickerPreviewExample data_testid="DC-04">
113///         <DigitalClock bind=value appearance=DigitalClockAppearance::time24() />
114///     </PickerPreviewExample>
115/// }
116/// ```
117#[component_doc(
118    category = "Calendar & Time",
119    preview_slug = "digital-clock",
120    preview_label = "Digital Clock",
121    preview_icon = icondata::AiFieldTimeOutlined,
122)]
123#[component]
124pub fn DigitalClock(
125    /// Value binding for the selected time-of-day.
126    #[prop(optional, into)]
127    bind: DigitalClockBind,
128    /// List step, label format, reference day, timezone, and disabled state.
129    #[prop(optional, into)]
130    appearance: DigitalClockAppearance,
131    /// Optional CSS class merged onto the layout root.
132    #[prop(optional, into)]
133    class: MaybeProp<String>,
134) -> impl IntoView {
135    let DigitalClockBind { value } = bind;
136    let DigitalClockAppearance {
137        time_step,
138        ampm,
139        reference_date,
140        timezone,
141        disabled,
142    } = appearance;
143
144    let value = StoredValue::new(value);
145    let locale = use_datetime_locale();
146    let theme_options = use_theme_options();
147
148    let resolved_timezone = Signal::derive(move || timezone.get());
149    let resolved_reference = Signal::derive(move || reference_date.get().start_of_day());
150
151    let slots = Signal::derive(move || generate_time_slots(time_step.get().max(1)));
152
153    let selected_slot = Signal::derive(move || {
154        value
155            .get_value()
156            .get()
157            .and_then(|dt| dt.hour_minute_second())
158            .map(|(h, m, _)| (h, m))
159    });
160
161    let selection_label = Signal::derive(move || {
162        value
163            .get_value()
164            .get()
165            .and_then(|dt| dt.hour_minute_second())
166            .map(|(hour, minute, _)| format_slot_label(hour, minute, ampm.get()))
167    });
168
169    let root_class = move || {
170        let mut parts = vec!["orb-picker-digital-clock".to_string()];
171        match theme_options.get().density {
172            orbital_theme::Density::Compact => {
173                parts.push("orb-picker-digital-clock--density-compact".to_string())
174            }
175            orbital_theme::Density::Spacious => {
176                parts.push("orb-picker-digital-clock--density-spacious".to_string())
177            }
178            orbital_theme::Density::Default => {}
179        }
180        if let Some(extra) = class.get() {
181            if !extra.is_empty() {
182                parts.push(extra);
183            }
184        }
185        let _ = locale.locale;
186        parts.join(" ")
187    };
188
189    let select_slot = move |hour: u32, minute: u32| {
190        if disabled.get_untracked() {
191            return;
192        }
193        let anchor = resolve_anchor(
194            value.get_value().get_untracked(),
195            resolved_reference.get_untracked(),
196            resolved_timezone.get_untracked(),
197        );
198        if let Some(committed) =
199            commit_time(anchor, hour, minute, 0, resolved_timezone.get_untracked())
200        {
201            value.get_value().set(Some(committed));
202        }
203    };
204
205    view! {
206        <style>{digital_clock_styles()}</style>
207        <style>{picker_style_sheet()}</style>
208        <div class=root_class data-orbital-picker="" role="group" aria-label="Digital clock">
209            <div
210                class=move || {
211                    if selection_label.get().is_some() {
212                        "orb-picker-digital-clock__readout".to_string()
213                    } else {
214                        "orb-picker-digital-clock__readout orb-picker-digital-clock__readout--placeholder".to_string()
215                    }
216                }
217                aria-live="polite"
218            >
219                {move || selection_label.get().unwrap_or_else(|| "Select a time".to_string())}
220            </div>
221            <ScrollArea class="orb-picker-digital-clock__scroll">
222                <List
223                    navigation_mode=ListNavigationMode::Nav
224                    selection_mode=ListSelectionMode::Single
225                >
226                    {move || {
227                        let use_ampm = ampm.get();
228                        slots
229                            .get()
230                            .into_iter()
231                            .map(|(hour, minute)| {
232                                let label = format_slot_label(hour, minute, use_ampm);
233                                let is_selected = selected_slot.get() == Some((hour, minute));
234                                view! {
235                                    <ListItem
236                                        class="orb-picker-digital-clock__item"
237                                        selected=Signal::from(is_selected)
238                                        on_click=Callback::new(move |_| select_slot(hour, minute))
239                                    >
240                                        {label}
241                                    </ListItem>
242                                }
243                            })
244                            .collect_view()
245                    }}
246                </List>
247            </ScrollArea>
248        </div>
249    }
250}