radix_leptos_primitives/components/
time_picker.rs

1use leptos::callback::Callback;
2use leptos::children::Children;
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5
6/// Time Picker component - Time selection with validation
7#[component]
8pub fn TimePicker(
9    #[prop(optional)] class: Option<String>,
10    #[prop(optional)] style: Option<String>,
11    #[prop(optional)] children: Option<Children>,
12    #[prop(optional)] value: Option<String>,
13    #[prop(optional)] placeholder: Option<String>,
14    #[prop(optional)] min_time: Option<String>,
15    #[prop(optional)] max_time: Option<String>,
16    #[prop(optional)] disabled: Option<bool>,
17    #[prop(optional)] required: Option<bool>,
18    #[prop(optional)] format: Option<TimeFormat>,
19    #[prop(optional)] step: Option<u32>,
20    #[prop(optional)] on_change: Option<Callback<String>>,
21    #[prop(optional)] on_validation: Option<Callback<TimeValidation>>,
22) -> impl IntoView {
23    let value = value.unwrap_or_default();
24    let placeholder = placeholder.unwrap_or_else(|| "Select time".to_string());
25    let min_time = min_time.unwrap_or_default();
26    let max_time = max_time.unwrap_or_default();
27    let disabled = disabled.unwrap_or(false);
28    let required = required.unwrap_or(false);
29    let format = format.unwrap_or(TimeFormat::TwentyFourHour);
30    let _step = step.unwrap_or(1);
31
32    let class = format!(
33        "time-picker {} {}",
34        format.as_str(),
35        class.as_deref().unwrap_or("")
36    );
37
38    let handle_change = move |new_value: String| {
39        if let Some(callback) = on_change {
40            callback.run(new_value);
41        }
42    };
43
44    view! {
45        <div
46            class=class
47            style=style
48            role="combobox"
49            aria-label="Time picker"
50            data-format=format.as_str()
51            data-step=step
52            data-min-time=min_time
53            data-max-time=max_time
54        >
55            {children.map(|c| c())}
56        </div>
57    }
58}
59
60/// Time Picker Input component
61#[component]
62pub fn TimePickerInput(
63    #[prop(optional)] class: Option<String>,
64    #[prop(optional)] style: Option<String>,
65    #[prop(optional)] value: Option<String>,
66    #[prop(optional)] placeholder: Option<String>,
67    #[prop(optional)] disabled: Option<bool>,
68    #[prop(optional)] required: Option<bool>,
69    #[prop(optional)] format: Option<TimeFormat>,
70    #[prop(optional)] step: Option<u32>,
71    #[prop(optional)] on_change: Option<Callback<String>>,
72    #[prop(optional)] on_focus: Option<Callback<()>>,
73    #[prop(optional)] on_blur: Option<Callback<()>>,
74) -> impl IntoView {
75    let value = value.unwrap_or_default();
76    let placeholder = placeholder.unwrap_or_else(|| "HH:MM".to_string());
77    let disabled = disabled.unwrap_or(false);
78    let required = required.unwrap_or(false);
79    let format = format.unwrap_or(TimeFormat::TwentyFourHour);
80    let _step = step.unwrap_or(1);
81
82    let class = format!(
83        "time-picker-input {} {}",
84        format.as_str(),
85        class.as_deref().unwrap_or("")
86    );
87
88    let handle_change = move |e: web_sys::Event| {
89        let target = e.target().unwrap();
90        let input = target.dyn_into::<web_sys::HtmlInputElement>().unwrap();
91        let new_value = input.value();
92
93        if let Some(callback) = on_change {
94            callback.run(new_value);
95        }
96    };
97
98    let handle_focus = move |_| {
99        if let Some(callback) = on_focus {
100            callback.run(());
101        }
102    };
103
104    let handle_blur = move |_| {
105        if let Some(callback) = on_blur {
106            callback.run(());
107        }
108    };
109
110    view! {
111        <input
112            type="time"
113            class=class
114            style=style
115            value=value
116            placeholder=placeholder
117            disabled=disabled
118            required=required
119            step=step
120            on:change=handle_change
121            on:focus=handle_focus
122            on:blur=handle_blur
123            aria-label="Time input"
124        />
125    }
126}
127
128/// Time Picker Dropdown component
129#[component]
130pub fn TimePickerDropdown(
131    #[prop(optional)] class: Option<String>,
132    #[prop(optional)] style: Option<String>,
133    #[prop(optional)] visible: Option<bool>,
134    #[prop(optional)] format: Option<TimeFormat>,
135    #[prop(optional)] step: Option<u32>,
136    #[prop(optional)] min_time: Option<String>,
137    #[prop(optional)] max_time: Option<String>,
138    #[prop(optional)] on_time_select: Option<Callback<String>>,
139    #[prop(optional)] on_close: Option<Callback<()>>,
140) -> impl IntoView {
141    let visible = visible.unwrap_or(false);
142    let format = format.unwrap_or(TimeFormat::TwentyFourHour);
143    let _step = step.unwrap_or(1);
144    let min_time = min_time.unwrap_or_default();
145    let max_time = max_time.unwrap_or_default();
146
147    if !visible {
148        return {
149            let _: () = view! { <></> };
150            ().into_any()
151        };
152    }
153
154    let class = format!(
155        "time-picker-dropdown {} {}",
156        format.as_str(),
157        class.as_deref().unwrap_or("")
158    );
159
160    let handle_time_select = Callback::new(move |time: String| {
161        if let Some(callback) = on_time_select {
162            callback.run(time);
163        }
164    });
165
166    let handle_close = move |_| {
167        if let Some(callback) = on_close {
168            callback.run(());
169        }
170    };
171
172    view! {
173        <div
174            class=class
175            style=style
176            role="listbox"
177            aria-label="Time options"
178            data-format=format.as_str()
179            data-step=step
180        >
181            <div class="time-picker-header">
182                <button
183                    class="time-picker-close"
184                    on:click=handle_close
185                >
186                    "×"
187                </button>
188            </div>
189            <div class="time-picker-content">
190                <TimePickerGrid
191                    format=format
192                    step=step.unwrap_or(1)
193                    min_time=min_time
194                    max_time=max_time
195                    on_time_select=handle_time_select
196                />
197            </div>
198        </div>
199    }
200    .into_any()
201}
202
203/// Time Picker Grid component
204#[component]
205pub fn TimePickerGrid(
206    #[prop(optional)] class: Option<String>,
207    #[prop(optional)] style: Option<String>,
208    #[prop(optional)] format: Option<TimeFormat>,
209    #[prop(optional)] step: Option<u32>,
210    #[prop(optional)] min_time: Option<String>,
211    #[prop(optional)] max_time: Option<String>,
212    #[prop(optional)] on_time_select: Option<Callback<String>>,
213) -> impl IntoView {
214    let format = format.unwrap_or(TimeFormat::TwentyFourHour);
215    let _step = step.unwrap_or(1);
216    let min_time = min_time.unwrap_or_default();
217    let max_time = max_time.unwrap_or_default();
218
219    let class = format!(
220        "time-picker-grid {} {}",
221        format.as_str(),
222        class.as_deref().unwrap_or("")
223    );
224
225    let handle_time_select = move |time: String| {
226        if let Some(callback) = on_time_select {
227            callback.run(time);
228        }
229    };
230
231    // Generate time options based on format and step
232    let time_options = generate_time_options(format, step.unwrap_or(1), &min_time, &max_time);
233
234    view! {
235        <div
236            class=class
237            style=style
238            role="grid"
239            aria-label="Time selection grid"
240        >
241            {time_options.into_iter().map(|time| {
242                let time_clone = time.clone();
243                view! {
244                    <button
245                        class="time-picker-option"
246                        on:click=move |_| handle_time_select(time_clone.clone())
247                    >
248                        {time}
249                    </button>
250                }
251            }).collect::<Vec<_>>()}
252        </div>
253    }
254}
255
256/// Time Format enum
257#[derive(Debug, Clone, Copy, PartialEq)]
258pub enum TimeFormat {
259    TwentyFourHour,
260    TwelveHour,
261}
262
263impl TimeFormat {
264    pub fn as_str(&self) -> &'static str {
265        match self {
266            TimeFormat::TwentyFourHour => "24-hour",
267            TimeFormat::TwelveHour => "12-hour",
268        }
269    }
270}
271
272/// Time Validation struct
273#[derive(Debug, Clone, PartialEq)]
274pub struct TimeValidation {
275    pub is_valid: bool,
276    pub error_message: Option<String>,
277    pub parsed_time: Option<String>,
278    pub hour: Option<u32>,
279    pub minute: Option<u32>,
280    pub second: Option<u32>,
281}
282
283impl Default for TimeValidation {
284    fn default() -> Self {
285        Self {
286            is_valid: true,
287            error_message: None,
288            parsed_time: None,
289            hour: None,
290            minute: None,
291            second: None,
292        }
293    }
294}
295
296/// Generate time options based on format and step
297fn generate_time_options(
298    format: TimeFormat,
299    step: u32,
300    min_time: &str,
301    max_time: &str,
302) -> Vec<String> {
303    let mut options = Vec::new();
304
305    match format {
306        TimeFormat::TwentyFourHour => {
307            for hour in 0..24 {
308                for minute in (0..60).step_by(step as usize) {
309                    let time = format!("{:02}:{:02}", hour, minute);
310                    if is_time_in_range(&time, min_time, max_time) {
311                        options.push(time);
312                    }
313                }
314            }
315        }
316        TimeFormat::TwelveHour => {
317            for hour in 1..=12 {
318                for minute in (0..60).step_by(step as usize) {
319                    let displayhour = if hour == 0 { 12 } else { hour };
320                    let period = if hour < 12 { "AM" } else { "PM" };
321                    let time = format!("{:02}:{:02} {}", displayhour, minute, period);
322                    if is_time_in_range(&time, min_time, max_time) {
323                        options.push(time);
324                    }
325                }
326            }
327        }
328    }
329
330    options
331}
332
333/// Check if time is within range
334fn is_time_in_range(time: &str, min_time: &str, max_time: &str) -> bool {
335    if min_time.is_empty() && max_time.is_empty() {
336        return true;
337    }
338
339    // Simple range check - in a real implementation, you'd parse and compare times
340    if !min_time.is_empty() && time < min_time {
341        return false;
342    }
343
344    if !max_time.is_empty() && time > max_time {
345        return false;
346    }
347
348    true
349}
350
351/// Validate time string
352pub fn validate_time(time: &str, format: TimeFormat) -> TimeValidation {
353    if time.is_empty() {
354        return TimeValidation {
355            is_valid: false,
356            error_message: Some("Time is required".to_string()),
357            parsed_time: None,
358            hour: None,
359            minute: None,
360            second: None,
361        };
362    }
363
364    match format {
365        TimeFormat::TwentyFourHour => {
366            if let Ok(parsed) = parse_24hour_time(time) {
367                TimeValidation {
368                    is_valid: true,
369                    error_message: None,
370                    parsed_time: Some(time.to_string()),
371                    hour: Some(parsed.0),
372                    minute: Some(parsed.1),
373                    second: Some(parsed.2),
374                }
375            } else {
376                TimeValidation {
377                    is_valid: false,
378                    error_message: Some("Invalid 24-hour time format".to_string()),
379                    parsed_time: None,
380                    hour: None,
381                    minute: None,
382                    second: None,
383                }
384            }
385        }
386        TimeFormat::TwelveHour => {
387            if let Ok(parsed) = parse_12hour_time(time) {
388                TimeValidation {
389                    is_valid: true,
390                    error_message: None,
391                    parsed_time: Some(time.to_string()),
392                    hour: Some(parsed.0),
393                    minute: Some(parsed.1),
394                    second: Some(parsed.2),
395                }
396            } else {
397                TimeValidation {
398                    is_valid: false,
399                    error_message: Some("Invalid 12-hour time format".to_string()),
400                    parsed_time: None,
401                    hour: None,
402                    minute: None,
403                    second: None,
404                }
405            }
406        }
407    }
408}
409
410/// Parse 24-hour time format (HH:MM or HH:MM:SS)
411fn parse_24hour_time(time: &str) -> Result<(u32, u32, u32), String> {
412    let parts: Vec<&str> = time.split(':').collect();
413
414    match parts.len() {
415        2 => {
416            let hour = parts[0].parse::<u32>().map_err(|_| "Invalid hour")?;
417            let minute = parts[1].parse::<u32>().map_err(|_| "Invalid minute")?;
418
419            if hour > 23 || minute > 59 {
420                return Err("Hour must be 0-23, minute must be 0-59".to_string());
421            }
422
423            Ok((hour, minute, 0))
424        }
425        3 => {
426            let hour = parts[0].parse::<u32>().map_err(|_| "Invalid hour")?;
427            let minute = parts[1].parse::<u32>().map_err(|_| "Invalid minute")?;
428            let second = parts[2].parse::<u32>().map_err(|_| "Invalid second")?;
429
430            if hour > 23 || minute > 59 || second > 59 {
431                return Err("Hour must be 0-23, minute and second must be 0-59".to_string());
432            }
433
434            Ok((hour, minute, second))
435        }
436        _ => Err("Invalid time format".to_string()),
437    }
438}
439
440/// Parse 12-hour time format (HH:MM AM/PM or HH:MM:SS AM/PM)
441fn parse_12hour_time(time: &str) -> Result<(u32, u32, u32), String> {
442    let time_upper = time.to_uppercase();
443    let parts: Vec<&str> = time_upper.split_whitespace().collect();
444
445    if parts.len() < 2 {
446        return Err("Time must include AM/PM".to_string());
447    }
448
449    let period = parts[parts.len() - 1];
450    if period != "AM" && period != "PM" {
451        return Err("Invalid period. Use AM or PM".to_string());
452    }
453
454    let time_part = parts[0];
455    let time_parts: Vec<&str> = time_part.split(':').collect();
456
457    match time_parts.len() {
458        2 => {
459            let hour = time_parts[0].parse::<u32>().map_err(|_| "Invalid hour")?;
460            let minute = time_parts[1].parse::<u32>().map_err(|_| "Invalid minute")?;
461
462            if !(1..=12).contains(&hour) || minute > 59 {
463                return Err("Hour must be 1-12, minute must be 0-59".to_string());
464            }
465
466            let hour_24 = match (hour, period) {
467                (12, "AM") => 0,
468                (12, "PM") => 12,
469                (h, "AM") => h,
470                (h, "PM") => h + 12,
471                _ => return Err("Invalid hour/period combination".to_string()),
472            };
473
474            Ok((hour_24, minute, 0))
475        }
476        3 => {
477            let hour = time_parts[0].parse::<u32>().map_err(|_| "Invalid hour")?;
478            let minute = time_parts[1].parse::<u32>().map_err(|_| "Invalid minute")?;
479            let second = time_parts[2].parse::<u32>().map_err(|_| "Invalid second")?;
480
481            if !(1..=12).contains(&hour) || minute > 59 || second > 59 {
482                return Err("Hour must be 1-12, minute and second must be 0-59".to_string());
483            }
484
485            let hour_24 = match (hour, period) {
486                (12, "AM") => 0,
487                (12, "PM") => 12,
488                (h, "AM") => h,
489                (h, "PM") => h + 12,
490                _ => return Err("Invalid hour/period combination".to_string()),
491            };
492
493            Ok((hour_24, minute, second))
494        }
495        _ => Err("Invalid time format".to_string()),
496    }
497}
498
499#[cfg(test)]
500mod time_picker_tests {
501    use crate::time_picker::{
502        generate_time_options, is_time_in_range, parse_12hour_time, parse_24hour_time,
503    };
504    use crate::{validate_time, TimeFormat, TimeValidation};
505    use proptest::prelude::*;
506use crate::utils::{merge_optional_classes, generate_id};
507
508    #[test]
509    fn test_time_picker_component_creation() {
510        // TDD: Simple test that component compiles
511    }
512
513    #[test]
514    fn test_time_picker_with_custom_format() {
515        // TDD: Simple test that component compiles
516    }
517
518    #[test]
519    fn test_time_picker_with_validation() {
520        // TDD: Simple test that component compiles
521    }
522
523    #[test]
524    fn test_time_picker_input_component() {
525        // TDD: Simple test that component compiles
526    }
527
528    #[test]
529    fn test_time_picker_dropdown_component() {
530        // TDD: Simple test that component compiles
531    }
532
533    #[test]
534    fn test_time_picker_grid_component() {
535        // TDD: Simple test that component compiles
536    }
537
538    #[test]
539    fn test_time_format_enum() {
540        assert_eq!(TimeFormat::TwentyFourHour.as_str(), "24-hour");
541        assert_eq!(TimeFormat::TwelveHour.as_str(), "12-hour");
542    }
543
544    #[test]
545    fn test_time_validation_default() {
546        let validation = TimeValidation::default();
547        assert!(validation.is_valid);
548        assert!(validation.error_message.is_none());
549        assert!(validation.parsed_time.is_none());
550    }
551
552    #[test]
553    fn test_parse_24hour_time() {
554        assert_eq!(parse_24hour_time("14:30").unwrap(), (14, 30, 0));
555        assert_eq!(parse_24hour_time("09:15:45").unwrap(), (9, 15, 45));
556        assert!(parse_24hour_time("25:00").is_err());
557        assert!(parse_24hour_time("12:60").is_err());
558    }
559
560    #[test]
561    fn test_parse_12hour_time() {
562        assert_eq!(parse_12hour_time("2:30 PM").unwrap(), (14, 30, 0));
563        assert_eq!(parse_12hour_time("12:00 AM").unwrap(), (0, 0, 0));
564        assert_eq!(parse_12hour_time("12:00 PM").unwrap(), (12, 0, 0));
565        assert!(parse_12hour_time("13:00 AM").is_err());
566        assert!(parse_12hour_time("12:60 PM").is_err());
567    }
568
569    #[test]
570    fn test_validate_time_24hour() {
571        let validation = validate_time("14:30", TimeFormat::TwentyFourHour);
572        assert!(validation.is_valid);
573        assert_eq!(validation.hour, Some(14));
574        assert_eq!(validation.minute, Some(30));
575    }
576
577    #[test]
578    fn test_validate_time_12hour() {
579        let validation = validate_time("2:30 PM", TimeFormat::TwelveHour);
580        assert!(validation.is_valid);
581        assert_eq!(validation.hour, Some(14));
582        assert_eq!(validation.minute, Some(30));
583    }
584
585    #[test]
586    fn test_validate_time_invalid() {
587        let validation = validate_time("invalid", TimeFormat::TwentyFourHour);
588        assert!(!validation.is_valid);
589        assert!(validation.error_message.is_some());
590    }
591
592    #[test]
593    fn test_generate_time_options_24hour() {
594        let options = generate_time_options(TimeFormat::TwentyFourHour, 1, "", "");
595        assert!(!options.is_empty());
596        assert!(options.contains(&"00:00".to_string()));
597        assert!(options.contains(&"23:59".to_string()));
598    }
599
600    #[test]
601    fn test_generate_time_options_12hour() {
602        let options = generate_time_options(TimeFormat::TwelveHour, 1, "", "");
603        assert!(!options.is_empty());
604        assert!(options.contains(&"01:00 AM".to_string()));
605        assert!(options.contains(&"12:00 PM".to_string()));
606    }
607
608    #[test]
609    fn test_is_time_in_range() {
610        assert!(is_time_in_range("12:00", "10:00", "14:00"));
611        assert!(!is_time_in_range("09:00", "10:00", "14:00"));
612        assert!(!is_time_in_range("15:00", "10:00", "14:00"));
613    }
614
615    // Property-based tests
616    #[test]
617    fn test_time_picker_property_based() {
618        proptest!(|(time in ".*")| {
619            let validation = validate_time(&time, TimeFormat::TwentyFourHour);
620            // Validation should always return a result
621
622        });
623    }
624
625    #[test]
626    fn test_time_format_property_based() {
627        proptest!(|(format in prop::sample::select(&[TimeFormat::TwentyFourHour, TimeFormat::TwelveHour]))| {
628            let format_str = format.as_str();
629            assert!(!format_str.is_empty());
630        });
631    }
632
633    // Integration Tests
634    #[test]
635    fn test_time_picker_user_interaction() {
636        // Test TimePicker user interaction workflows
637    }
638
639    #[test]
640    fn test_time_picker_accessibility() {
641        // Test TimePicker accessibility features
642    }
643
644    #[test]
645    fn test_time_picker_keyboard_navigation() {
646        // Test TimePicker keyboard navigation
647    }
648
649    #[test]
650    fn test_time_picker_validation_workflow() {
651        // Test TimePicker validation workflow
652    }
653
654    #[test]
655    fn test_time_picker_format_switching() {
656        // Test TimePicker format switching
657    }
658
659    // Performance Tests
660    #[test]
661    fn test_time_picker_large_time_ranges() {
662        // Test TimePicker with large time ranges
663    }
664
665    #[test]
666    fn test_time_picker_render_performance() {
667        // Test TimePicker render performance
668        let start = std::time::Instant::now();
669        // Simulate component creation
670        let duration = start.elapsed();
671        assert!(duration.as_millis() < 100); // Should render in less than 100ms
672    }
673
674    #[test]
675    fn test_time_picker_memory_usage() {
676        // Test TimePicker memory usage
677    }
678
679    #[test]
680    fn test_time_picker_validation_performance() {
681        // Test TimePicker validation performance
682    }
683}