1use leptos::callback::Callback;
2use leptos::children::Children;
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5
6#[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#[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#[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#[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 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#[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#[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
296fn 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
333fn 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 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
351pub 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
410fn 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
440fn 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 }
512
513 #[test]
514 fn test_time_picker_with_custom_format() {
515 }
517
518 #[test]
519 fn test_time_picker_with_validation() {
520 }
522
523 #[test]
524 fn test_time_picker_input_component() {
525 }
527
528 #[test]
529 fn test_time_picker_dropdown_component() {
530 }
532
533 #[test]
534 fn test_time_picker_grid_component() {
535 }
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 #[test]
617 fn test_time_picker_property_based() {
618 proptest!(|(time in ".*")| {
619 let validation = validate_time(&time, TimeFormat::TwentyFourHour);
620 });
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 #[test]
635 fn test_time_picker_user_interaction() {
636 }
638
639 #[test]
640 fn test_time_picker_accessibility() {
641 }
643
644 #[test]
645 fn test_time_picker_keyboard_navigation() {
646 }
648
649 #[test]
650 fn test_time_picker_validation_workflow() {
651 }
653
654 #[test]
655 fn test_time_picker_format_switching() {
656 }
658
659 #[test]
661 fn test_time_picker_large_time_ranges() {
662 }
664
665 #[test]
666 fn test_time_picker_render_performance() {
667 let start = std::time::Instant::now();
669 let duration = start.elapsed();
671 assert!(duration.as_millis() < 100); }
673
674 #[test]
675 fn test_time_picker_memory_usage() {
676 }
678
679 #[test]
680 fn test_time_picker_validation_performance() {
681 }
683}