Skip to main content

orbital_date_pickers/building_blocks/segmented_field/
component.rs

1use std::sync::Arc;
2
3use leptos::{html, prelude::*};
4use orbital_base_components::{DatetimeFormat, DatetimeTimezone, OptionBind, OrbitalDateTime};
5use orbital_core_components::input_styles;
6use orbital_theme::use_theme_options;
7
8use crate::shared::{
9    datetime_field_root_classes, datetime_segment_class, datetime_separator_class,
10    field_root_classes, picker_class_names, picker_style_sheet, time_segment_class,
11    time_separator_class,
12};
13
14use super::parse::{
15    datetime_to_combined_segments, datetime_to_segments, parse_date_segments,
16    parse_datetime_segments, parse_time_segments, segments_complete,
17};
18use super::sections::{combined_segment_specs, segment_specs, SegmentKind, SegmentSpec};
19use super::styles::{segment_input_style, segmented_field_styles};
20use super::validation::{
21    normalize_segment_input, numeric_bounds, pad_segment_on_blur, segment_field_format,
22    segment_is_complete,
23};
24
25/// Low-level segmented datetime input shared by [`crate::DateField`], [`crate::TimeField`],
26/// and [`crate::DateTimeField`].
27#[component]
28pub fn SegmentedDatetimeField(
29    /// Two-way [`OrbitalDateTime`] binding.
30    #[prop(optional, into)]
31    value: OptionBind<OrbitalDateTime>,
32    /// Display and parse format controlling segment layout (date mask in datetime mode).
33    #[prop(into)]
34    format: Signal<DatetimeFormat>,
35    /// Timezone used when parsing date segments.
36    #[prop(into)]
37    timezone: Signal<DatetimeTimezone>,
38    /// Calendar day anchor for time-only parsing.
39    #[prop(into)]
40    reference_date: Signal<OrbitalDateTime>,
41    /// Disables all segment inputs.
42    #[prop(into)]
43    disabled: Signal<bool>,
44    /// Prefix for `data-testid` on each segment (`{prefix}-segment-{kind}`).
45    #[prop(into)]
46    testid_prefix: &'static str,
47    /// When true, uses time segment layout and parsing.
48    #[prop(default = false)]
49    is_time: bool,
50    /// When true, appends time segments from `time_format` after date segments.
51    #[prop(default = false)]
52    combined: bool,
53    /// Time mask used when `combined` is true.
54    #[prop(optional, into, default = Signal::from(DatetimeFormat::Time12))]
55    time_format: Signal<DatetimeFormat>,
56    /// Optional control id applied to the first segment.
57    #[prop(optional, into)]
58    id: MaybeProp<String>,
59    /// Optional form name applied to the first segment.
60    #[prop(optional, into)]
61    name: MaybeProp<String>,
62) -> impl IntoView {
63    let value = StoredValue::new(value);
64    let theme_options = use_theme_options();
65    let class_names = picker_class_names();
66    let is_datetime = move || combined;
67
68    let segment_class = move || {
69        if is_datetime() {
70            datetime_segment_class().to_string()
71        } else if is_time {
72            time_segment_class().to_string()
73        } else {
74            class_names.segment.clone()
75        }
76    };
77    let separator_class = move || {
78        if is_datetime() {
79            datetime_separator_class().to_string()
80        } else if is_time {
81            time_separator_class().to_string()
82        } else {
83            class_names.separator.clone()
84        }
85    };
86
87    let segments = RwSignal::new(Vec::<String>::new());
88    let last_committed = RwSignal::new(None::<OrbitalDateTime>);
89    let active_specs = RwSignal::new(Vec::<SegmentSpec>::new());
90    let editing = RwSignal::new(false);
91
92    let sync_segments_from_value = move || {
93        let bound = value.with_value(|v| v.get_untracked());
94        if is_datetime() {
95            let date_fmt = format.get_untracked();
96            let time_fmt = time_format.get_untracked();
97            active_specs.set(combined_segment_specs(date_fmt, time_fmt));
98            segments.set(datetime_to_combined_segments(bound, date_fmt, time_fmt));
99        } else {
100            let fmt = format.get_untracked();
101            active_specs.set(segment_specs(fmt).to_vec());
102            segments.set(datetime_to_segments(bound, fmt));
103        }
104    };
105
106    sync_segments_from_value();
107
108    Effect::new(move |_| {
109        if editing.get() {
110            return;
111        }
112        let bound = value.with_value(|v| v.get());
113        if last_committed.get_untracked() != bound {
114            last_committed.set(bound);
115            if is_datetime() {
116                let date_fmt = format.get();
117                let time_fmt = time_format.get();
118                active_specs.set(combined_segment_specs(date_fmt, time_fmt));
119                segments.set(datetime_to_combined_segments(bound, date_fmt, time_fmt));
120            } else {
121                let fmt = format.get();
122                active_specs.set(segment_specs(fmt).to_vec());
123                segments.set(datetime_to_segments(bound, fmt));
124            }
125        }
126    });
127
128    Effect::new(move |_| {
129        if editing.get() {
130            return;
131        }
132        if is_datetime() {
133            let date_fmt = format.get();
134            let time_fmt = time_format.get();
135            let specs = combined_segment_specs(date_fmt, time_fmt);
136            if active_specs.get_untracked() != specs {
137                active_specs.set(specs);
138                sync_segments_from_value();
139            }
140        } else {
141            let fmt = format.get();
142            let specs = segment_specs(fmt).to_vec();
143            if active_specs.get_untracked() != specs {
144                active_specs.set(specs);
145                sync_segments_from_value();
146            }
147        }
148    });
149
150    let commit = move || {
151        let current = segments.get();
152        let tz = timezone.get();
153        let parsed = if is_datetime() {
154            let date_fmt = format.get();
155            let time_fmt = time_format.get();
156            parse_datetime_segments(&current, date_fmt, time_fmt, tz)
157        } else if is_time {
158            parse_time_segments(&current, format.get(), tz, reference_date.get())
159        } else {
160            parse_date_segments(&current, format.get(), tz)
161        };
162
163        match parsed {
164            Some(dt) => {
165                last_committed.set(Some(dt));
166                value.with_value(|v| v.set(Some(dt)));
167            }
168            None if current.iter().all(|part| part.trim().is_empty()) => {
169                last_committed.set(None);
170                value.with_value(|v| v.set(None));
171            }
172            None => {}
173        }
174    };
175
176    let root_class = move || {
177        let mut parts = if is_datetime() {
178            vec![datetime_field_root_classes(theme_options.get().density)]
179        } else {
180            vec![field_root_classes(is_time, theme_options.get().density)]
181        };
182        if disabled.get() {
183            parts.push("orbital-input--disabled".to_string());
184        }
185        parts.join(" ")
186    };
187
188    let field_root = NodeRef::<html::Span>::new();
189
190    view! {
191        <style>{input_styles()}</style>
192        <style>{segmented_field_styles()}</style>
193        <style>{picker_style_sheet()}</style>
194        <span class=root_class role="group" node_ref=field_root>
195            {move || {
196                let specs = active_specs.get();
197                if specs.is_empty() {
198                    ().into_any()
199                } else {
200                    let date_fmt = format.get();
201                    let time_fmt = if is_datetime() {
202                        time_format.get()
203                    } else if is_time {
204                        format.get()
205                    } else {
206                        DatetimeFormat::UsDate
207                    };
208                    render_specs(
209                        &specs,
210                        segments,
211                        segment_class(),
212                        separator_class(),
213                        disabled,
214                        editing,
215                        testid_prefix,
216                        id.get(),
217                        name.get(),
218                        commit,
219                        date_fmt,
220                        time_fmt,
221                        is_time,
222                        combined,
223                        field_root,
224                    )
225                    .into_any()
226                }
227            }}
228        </span>
229    }
230}
231
232#[allow(clippy::too_many_arguments)]
233fn render_specs(
234    specs: &[SegmentSpec],
235    segments: RwSignal<Vec<String>>,
236    segment_class: String,
237    separator_class: String,
238    disabled: Signal<bool>,
239    editing: RwSignal<bool>,
240    testid_prefix: &'static str,
241    id: Option<String>,
242    name: Option<String>,
243    commit: impl Fn() + Copy + 'static,
244    date_format: DatetimeFormat,
245    time_format: DatetimeFormat,
246    is_time: bool,
247    combined: bool,
248    field_root: NodeRef<html::Span>,
249) -> impl IntoView {
250    let specs = Arc::new(specs.to_vec());
251    specs
252        .iter()
253        .enumerate()
254        .map(|(index, spec)| {
255            let field_format =
256                segment_field_format(spec.kind, combined, is_time, date_format, time_format);
257            view! {
258                <SegmentInput
259                    index=index
260                    spec=*spec
261                    field_format=field_format
262                    specs=specs.clone()
263                    segments=segments
264                    segment_class=segment_class.clone()
265                    separator_class=separator_class.clone()
266                    disabled=disabled
267                    editing=editing
268                    testid_prefix=testid_prefix
269                    field_root=field_root
270                    id=id.clone().filter(|_| index == 0)
271                    name=name.clone().filter(|_| index == 0)
272                    commit=commit
273                />
274            }
275        })
276        .collect_view()
277}
278
279#[allow(clippy::too_many_arguments)]
280#[component]
281fn SegmentInput(
282    index: usize,
283    spec: SegmentSpec,
284    field_format: DatetimeFormat,
285    specs: Arc<Vec<SegmentSpec>>,
286    segments: RwSignal<Vec<String>>,
287    segment_class: String,
288    separator_class: String,
289    disabled: Signal<bool>,
290    editing: RwSignal<bool>,
291    testid_prefix: &'static str,
292    field_root: NodeRef<html::Span>,
293    id: Option<String>,
294    name: Option<String>,
295    commit: impl Fn() + Copy + 'static,
296) -> impl IntoView {
297    let specs_for_input = specs.clone();
298    let specs_for_blur = specs.clone();
299    let specs_for_change = specs.clone();
300    let testid = format!("{}-segment-{}", testid_prefix, spec.kind.testid_suffix());
301    let (aria_min, aria_max) = numeric_bounds(spec.kind, field_format);
302    let aria_min = aria_min as i32;
303    let aria_max = aria_max as i32;
304    let segment_text = RwSignal::new(
305        segments
306            .get_untracked()
307            .get(index)
308            .cloned()
309            .unwrap_or_default(),
310    );
311
312    Effect::new(move |_| {
313        if editing.get() {
314            return;
315        }
316        let parent = segments.get().get(index).cloned().unwrap_or_default();
317        if segment_text.get_untracked() != parent {
318            segment_text.set(parent);
319        }
320    });
321
322    let segment_classes = format!(
323        "orbital-input__input {} {}--{}",
324        segment_class,
325        segment_class,
326        spec.kind.modifier()
327    );
328    let segment_style = segment_input_style(spec.kind);
329    let inputmode = if spec.kind == SegmentKind::Meridiem {
330        "text"
331    } else {
332        "numeric"
333    };
334
335    view! {
336        <input
337            class=segment_classes
338            type="text"
339            inputmode=inputmode
340            prop:id=move || id.clone()
341            prop:name=move || name.clone()
342            prop:placeholder=spec.placeholder
343            prop:disabled=move || disabled.get()
344            prop:value=move || segment_text.get()
345            attr:aria-label=spec.kind.label()
346            role=if spec.kind == SegmentKind::Meridiem { "textbox" } else { "spinbutton" }
347            aria-valuemin=move || {
348                if spec.kind == SegmentKind::Meridiem {
349                    None
350                } else {
351                    Some(aria_min.to_string())
352                }
353            }
354            aria-valuemax=move || {
355                if spec.kind == SegmentKind::Meridiem {
356                    None
357                } else {
358                    Some(aria_max.to_string())
359                }
360            }
361            aria-valuenow=move || {
362                let text = segment_text.get();
363                spec.kind.aria_value(&text).map(|v| v.to_string())
364            }
365            data-testid=move || testid.clone()
366            prop:maxLength=spec.max_len
367            style=segment_style
368            on:focus=move |_| editing.set(true)
369            on:input=move |ev| {
370                let raw = event_target_value(&ev);
371                let normalized = normalize_segment_input(&raw, spec, field_format);
372                segment_text.set(normalized.clone());
373                segments.update(|values| {
374                    if values.len() <= index {
375                        values.resize(specs_for_input.len(), String::new());
376                    }
377                    values[index] = normalized.clone();
378                });
379                if segment_is_complete(&normalized, spec) {
380                    commit();
381                    focus_segment_at_index(field_root, index + 1);
382                } else {
383                    let specs = specs_for_input.as_ref();
384                    if segments_complete(segments.get_untracked().as_slice(), specs) {
385                        commit();
386                    }
387                }
388            }
389            on:blur=move |ev| {
390                let raw = event_target_value(&ev);
391                let normalized = normalize_segment_input(&raw, spec, field_format);
392                segment_text.set(normalized.clone());
393                segments.update(|values| {
394                    if values.len() <= index {
395                        values.resize(specs_for_blur.len(), String::new());
396                    }
397                    values[index] = normalized.clone();
398                });
399                let padded = pad_segment_on_blur(&normalized, spec);
400                if padded != normalized {
401                    segment_text.set(padded.clone());
402                    segments.update(|values| {
403                        if values.len() <= index {
404                            values.resize(specs_for_blur.len(), String::new());
405                        }
406                        values[index] = padded.clone();
407                    });
408                }
409                commit();
410                editing.set(false);
411            }
412            on:change=move |ev| {
413                let raw = event_target_value(&ev);
414                let normalized = normalize_segment_input(&raw, spec, field_format);
415                segment_text.set(normalized.clone());
416                segments.update(|values| {
417                    if values.len() <= index {
418                        values.resize(specs_for_change.len(), String::new());
419                    }
420                    values[index] = normalized.clone();
421                });
422                let padded = pad_segment_on_blur(&normalized, spec);
423                if padded != normalized {
424                    segment_text.set(padded.clone());
425                    segments.update(|values| {
426                        if values.len() <= index {
427                            values.resize(specs_for_change.len(), String::new());
428                        }
429                        values[index] = padded.clone();
430                    });
431                }
432                commit();
433            }
434            on:keydown=move |ev| {
435                let key = ev.key();
436                if key == "ArrowRight" {
437                    ev.prevent_default();
438                    focus_segment_at_index(field_root, index + 1);
439                } else if key == "ArrowLeft" {
440                    ev.prevent_default();
441                    if index > 0 {
442                        focus_segment_at_index(field_root, index - 1);
443                    }
444                } else if key == "Backspace"
445                    && segment_text.get_untracked().is_empty() && index > 0 {
446                        ev.prevent_default();
447                        focus_segment_at_index(field_root, index - 1);
448                    }
449            }
450        />
451        {spec.separator.map(|separator| {
452            view! {
453                <span class=separator_class.clone() aria-hidden="true">{separator}</span>
454            }
455        })}
456    }
457}
458
459fn focus_segment_at_index(field_root: NodeRef<html::Span>, segment_index: usize) {
460    #[cfg(target_arch = "wasm32")]
461    {
462        use leptos::leptos_dom::helpers::set_timeout;
463        use std::time::Duration;
464        use wasm_bindgen::JsCast;
465
466        let focus = move || {
467            let Some(root) = field_root.get() else {
468                return;
469            };
470            let Ok(inputs) = root.query_selector_all("input.orbital-input__input") else {
471                return;
472            };
473            if segment_index >= inputs.length() as usize {
474                return;
475            }
476            let Some(node) = inputs.item(segment_index as u32) else {
477                return;
478            };
479            if let Some(input) = node.dyn_ref::<web_sys::HtmlInputElement>() {
480                let _ = input.focus();
481                let len = input.value().len() as u32;
482                let _ = input.set_selection_range(0, len);
483            }
484        };
485
486        focus();
487        set_timeout(focus, Duration::from_millis(16));
488    }
489
490    #[cfg(not(target_arch = "wasm32"))]
491    {
492        let _ = (field_root, segment_index);
493    }
494}