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}