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}