orbital_date_pickers/building_blocks/segmented_field/
component.rs1use 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#[component]
28pub fn SegmentedDatetimeField(
29 #[prop(optional, into)]
31 value: OptionBind<OrbitalDateTime>,
32 #[prop(into)]
34 format: Signal<DatetimeFormat>,
35 #[prop(into)]
37 timezone: Signal<DatetimeTimezone>,
38 #[prop(into)]
40 reference_date: Signal<OrbitalDateTime>,
41 #[prop(into)]
43 disabled: Signal<bool>,
44 #[prop(into)]
46 testid_prefix: &'static str,
47 #[prop(default = false)]
49 is_time: bool,
50 #[prop(default = false)]
52 combined: bool,
53 #[prop(optional, into, default = Signal::from(DatetimeFormat::Time12))]
55 time_format: Signal<DatetimeFormat>,
56 #[prop(optional, into)]
58 id: MaybeProp<String>,
59 #[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(¤t, date_fmt, time_fmt, tz)
157 } else if is_time {
158 parse_time_segments(¤t, format.get(), tz, reference_date.get())
159 } else {
160 parse_date_segments(¤t, 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}