Skip to main content

orbital_date_pickers/building_blocks/time_clock/
mod.rs

1mod clock_number;
2mod dial;
3mod interaction;
4mod pointer;
5mod styles;
6
7use leptos::prelude::*;
8use orbital_macros::component_doc;
9use orbital_theme::use_theme_options;
10
11use crate::shared::{
12    commit_time, now_on_anchor, picker_style_sheet, resolve_anchor, snap_minute,
13    use_datetime_locale,
14};
15
16use super::field_types::{TimeClockAppearance, TimeClockBind};
17
18pub use dial::{ClockView, TimeClockDial};
19use styles::time_clock_styles;
20
21/// Analog clock surface for selecting time-of-day, bound to [`OrbitalDateTime`].
22///
23/// TimeClock renders a two-step SVG dial: pick an hour, then pick a minute. Values anchor
24/// to `appearance.reference_date` or the nearest [`DatetimeLocale`](crate::DatetimeLocale) default.
25/// Clock surfaces are optional product features documented via [`DatePickerFeatures::CLOCK_VIEWS`]
26/// — there is no runtime license check.
27///
28/// # When to use
29///
30/// - Visual time selection in picker panels or dialogs
31/// - Alternatives to scroll-column [`TimePicker`](orbital_core_components::TimePicker) surfaces
32///
33/// # Usage
34///
35/// 1. Bind `Option<OrbitalDateTime>` via [`TimeClockBind`].
36/// 2. Set `appearance.ampm` for 12-hour vs 24-hour dial.
37/// 3. Wrap preview examples in a native element with `data-testid`.
38///
39/// # Best Practices
40///
41/// ## Do's
42///
43/// * Enable clock views in product docs with [`DatePickerFeatures::CLOCK_VIEWS`]
44/// * Provide `reference_date` when the anchor day differs from locale defaults
45///
46/// ## Don'ts
47///
48/// * Do not use for date selection — prefer [`DateCalendar`](crate::DateCalendar)
49/// * Do not put `data-testid` on the component — wrap with a native element
50///
51/// # Examples
52///
53/// ## Analog clock
54/// Default 12-hour dial with bind readout for E2E.
55/// <!-- preview -->
56/// ```rust
57/// use orbital_base_components::{OrbitalDateTime, ToUnixSeconds};
58/// use crate::preview::{PickerPreviewExample, PickerPreviewKnobs};
59/// let value = RwSignal::new(None::<OrbitalDateTime>);
60/// view! {
61///     <PickerPreviewExample data_testid="time-clock-preview">
62///         <PickerPreviewKnobs />
63///         <TimeClock bind=value />
64///         <div data-testid="time-clock-preview-VALUE">{move || value.get().map(|v| v.to_unix_seconds().to_string()).unwrap_or_else(|| "none".to_string())}</div>
65///     </PickerPreviewExample>
66/// }
67/// ```
68///
69/// ## Hour and minute selection
70/// Click or drag on the dial to pick an hour, then a minute. Minute labels show five-minute increments.
71/// <!-- preview -->
72/// ```rust
73/// use orbital_base_components::{OrbitalDateTime, ToUnixSeconds};
74/// use crate::preview::PickerPreviewExample;
75/// let value = RwSignal::new(None::<OrbitalDateTime>);
76/// view! {
77///     <PickerPreviewExample data_testid="TC-02">
78///         <TimeClock bind=value />
79///         <div data-testid="TC-02-VALUE">{move || value.get().map(|v| v.to_unix_seconds().to_string()).unwrap_or_else(|| "none".to_string())}</div>
80///     </PickerPreviewExample>
81/// }
82/// ```
83///
84/// ## 24-hour dial
85/// Hour markers run 00–23 without meridiem controls.
86/// <!-- preview -->
87/// ```rust
88/// use orbital_base_components::OrbitalDateTime;
89/// use crate::preview::PickerPreviewExample;
90/// let value = RwSignal::new(None::<OrbitalDateTime>);
91/// view! {
92///     <PickerPreviewExample data_testid="TC-03">
93///         <TimeClock bind=value appearance=TimeClockAppearance::time24() />
94///     </PickerPreviewExample>
95/// }
96/// ```
97///
98/// ## Five-minute steps
99/// Five-minute minute labels with coarser snap via `minute_step`.
100/// <!-- preview -->
101/// ```rust
102/// use leptos::prelude::*;
103/// use orbital_base_components::OrbitalDateTime;
104/// use crate::preview::PickerPreviewExample;
105/// let value = RwSignal::new(None::<OrbitalDateTime>);
106/// view! {
107///     <PickerPreviewExample data_testid="TC-04">
108///         <TimeClock
109///             bind=value
110///             appearance=TimeClockAppearance {
111///                 minute_step: Signal::from(5),
112///                 ..Default::default()
113///             }
114///         />
115///     </PickerPreviewExample>
116/// }
117/// ```
118#[component_doc(
119    category = "Calendar & Time",
120    preview_slug = "time-clock",
121    preview_label = "Time Clock",
122    preview_icon = icondata::AiClockCircleOutlined,
123)]
124#[component]
125pub fn TimeClock(
126    /// Value binding for the selected time-of-day.
127    #[prop(optional, into)]
128    bind: TimeClockBind,
129    /// Dial format, minute step, reference day, timezone, and disabled state.
130    #[prop(optional, into)]
131    appearance: TimeClockAppearance,
132    /// Optional CSS class merged onto the layout root.
133    #[prop(optional, into)]
134    class: MaybeProp<String>,
135) -> impl IntoView {
136    let TimeClockBind { value } = bind;
137    let TimeClockAppearance {
138        ampm,
139        minute_step,
140        reference_date,
141        timezone,
142        disabled,
143    } = appearance;
144
145    let value = StoredValue::new(value);
146
147    let locale = use_datetime_locale();
148    let theme_options = use_theme_options();
149
150    let resolved_timezone = Signal::derive(move || timezone.get());
151    let resolved_reference = Signal::derive(move || reference_date.get().start_of_day());
152    let anchor = move || {
153        resolve_anchor(
154            value.get_value().get(),
155            resolved_reference.get(),
156            resolved_timezone.get(),
157        )
158    };
159
160    let view = RwSignal::new(ClockView::Hours);
161    let draft_hour_24 = RwSignal::new(0u32);
162    let draft_minute = RwSignal::new(0u32);
163    let is_pm = RwSignal::new(false);
164
165    Effect::new(move |_| {
166        let tz = resolved_timezone.get();
167        let anchor_day = anchor();
168        if let Some(current) = value.get_value().get() {
169            if let Some((hour, minute, _)) = current.hour_minute_second() {
170                draft_hour_24.set(hour);
171                draft_minute.set(minute);
172                is_pm.set(hour >= 12);
173                return;
174            }
175        }
176        let (hour, minute, pm) = now_on_anchor(anchor_day);
177        draft_hour_24.set(hour);
178        draft_minute.set(minute);
179        is_pm.set(pm);
180        let _ = tz;
181    });
182
183    let on_minute_selected = Callback::new(move |(hour, minute): (u32, u32)| {
184        if disabled.get_untracked() {
185            return;
186        }
187        let step = minute_step.get_untracked().max(1);
188        let snapped = snap_minute(minute, step);
189        if let Some(committed) = commit_time(
190            anchor(),
191            hour,
192            snapped,
193            0,
194            resolved_timezone.get_untracked(),
195        ) {
196            value.get_value().set(Some(committed));
197        }
198    });
199
200    let root_class = move || {
201        let mut parts = vec!["orb-picker-time-clock".to_string()];
202        match theme_options.get().density {
203            orbital_theme::Density::Compact => {
204                parts.push("orb-picker-time-clock--density-compact".to_string())
205            }
206            orbital_theme::Density::Spacious => {
207                parts.push("orb-picker-time-clock--density-spacious".to_string())
208            }
209            orbital_theme::Density::Default => {}
210        }
211        if let Some(extra) = class.get() {
212            if !extra.is_empty() {
213                parts.push(extra);
214            }
215        }
216        let _ = locale.locale;
217        parts.join(" ")
218    };
219
220    view! {
221        <style>{time_clock_styles()}</style>
222        <style>{picker_style_sheet()}</style>
223        <div class=root_class data-orbital-picker="" role="group" aria-label="Time clock">
224            <TimeClockDial
225                view=view
226                draft_hour_24=draft_hour_24
227                draft_minute=draft_minute
228                is_pm=is_pm
229                ampm=ampm
230                minute_step=minute_step
231                disabled=disabled
232                on_minute_selected=on_minute_selected
233            />
234        </div>
235    }
236}