1use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Props, Clone, PartialEq)]
12pub struct TimePickerProps {
13 #[props(default)]
15 pub value: Option<String>,
16 pub on_change: EventHandler<Option<String>>,
18 #[props(default = true)]
20 pub use_24h: bool,
21 #[props(default = false)]
23 pub show_seconds: bool,
24 #[props(default = false)]
26 pub disabled: bool,
27 #[props(default = 1)]
29 pub minute_step: u32,
30 #[props(default)]
32 pub placeholder: Option<String>,
33 #[props(default)]
35 pub label: Option<String>,
36 #[props(default)]
38 pub error: Option<String>,
39 #[props(default)]
41 pub class: Option<String>,
42}
43
44#[derive(Clone, PartialEq, Debug)]
46struct TimeValue {
47 hour: u32,
48 minute: u32,
49 second: u32,
50 is_pm: bool,
51}
52
53impl TimeValue {
54 fn from_string(value: &str, use_24h: bool) -> Option<Self> {
55 let parts: Vec<&str> = value.split(':').collect();
56 if parts.len() < 2 {
57 return None;
58 }
59
60 let hour = parts[0].parse().ok()?;
61 let minute = parts[1].parse().ok()?;
62 let second = if parts.len() > 2 {
63 parts[2].parse().ok()?
64 } else {
65 0
66 };
67
68 let (hour, is_pm) = if use_24h {
69 (hour, false)
70 } else {
71 if hour == 0 {
73 (12, false) } else if hour == 12 {
75 (12, true) } else if hour > 12 {
77 (hour - 12, true)
78 } else {
79 (hour, false)
80 }
81 };
82
83 Some(TimeValue {
84 hour,
85 minute,
86 second,
87 is_pm,
88 })
89 }
90
91 fn to_string(&self, use_24h: bool, show_seconds: bool) -> String {
92 let hour = if use_24h {
93 if self.is_pm && self.hour != 12 {
94 self.hour + 12
95 } else if !self.is_pm && self.hour == 12 {
96 0
97 } else {
98 self.hour
99 }
100 } else {
101 self.hour
102 };
103
104 if show_seconds {
105 format!("{:02}:{:02}:{:02}", hour, self.minute, self.second)
106 } else {
107 format!("{:02}:{:02}", hour, self.minute)
108 }
109 }
110
111 fn now(use_24h: bool) -> Self {
112 TimeValue {
115 hour: if use_24h { 12 } else { 12 },
116 minute: 0,
117 second: 0,
118 is_pm: true,
119 }
120 }
121}
122
123#[component]
142pub fn TimePicker(props: TimePickerProps) -> Element {
143 let theme = use_theme();
144 let mut is_open = use_signal(|| false);
145
146 let current_time = props
148 .value
149 .as_ref()
150 .and_then(|v| TimeValue::from_string(v, props.use_24h));
151
152 let class_css = props
153 .class
154 .as_ref()
155 .map(|c| format!(" {}", c))
156 .unwrap_or_default();
157
158 let border_color = if props.error.is_some() {
159 theme.tokens.read().colors.destructive.to_rgba()
160 } else if is_open() {
161 theme.tokens.read().colors.primary.to_rgba()
162 } else {
163 theme.tokens.read().colors.border.to_rgba()
164 };
165
166 let display_value = current_time.as_ref().map(|t| {
168 if props.use_24h {
169 if props.show_seconds {
170 format!("{:02}:{:02}:{:02}", t.hour, t.minute, t.second)
171 } else {
172 format!("{:02}:{:02}", t.hour, t.minute)
173 }
174 } else {
175 let am_pm = if t.is_pm { "PM" } else { "AM" };
176 if props.show_seconds {
177 format!("{:02}:{:02}:{:02} {}", t.hour, t.minute, t.second, am_pm)
178 } else {
179 format!("{:02}:{:02} {}", t.hour, t.minute, am_pm)
180 }
181 }
182 });
183
184 let has_error = props.error.is_some();
185 let is_disabled = props.disabled;
186 let trigger_style = use_style(move |t| {
187 Style::new()
188 .w_full()
189 .h_px(40)
190 .px(&t.spacing, "md")
191 .rounded(&t.radius, "md")
192 .border(
193 1,
194 if has_error {
195 &t.colors.destructive
196 } else {
197 &t.colors.border
198 },
199 )
200 .bg(&t.colors.background)
201 .text_color(&t.colors.foreground)
202 .font_size(14)
203 .cursor(if is_disabled {
204 "not-allowed"
205 } else {
206 "pointer"
207 })
208 .inline_flex()
209 .items_center()
210 .justify_between()
211 .transition("all 150ms ease")
212 .build()
213 });
214
215 let on_select_time = {
216 let on_change = props.on_change.clone();
217 let use_24h = props.use_24h;
218 let show_seconds = props.show_seconds;
219 move |time: TimeValue| {
220 let value = time.to_string(use_24h, show_seconds);
221 on_change.call(Some(value));
222 is_open.set(false);
223 }
224 };
225
226 let on_clear = {
227 let on_change = props.on_change.clone();
228 move |e: Event<MouseData>| {
229 e.stop_propagation();
230 on_change.call(None);
231 }
232 };
233
234 let disabled_style = if props.disabled { "opacity: 0.5;" } else { "" };
235 let placeholder_text = props
236 .placeholder
237 .clone()
238 .unwrap_or_else(|| "Select time".to_string());
239
240 rsx! {
241 div {
242 class: "time-picker{class_css}",
243 style: "display: flex; flex-direction: column; gap: 6px; position: relative;",
244
245 if let Some(label) = props.label.clone() {
246 label {
247 class: "time-picker-label",
248 style: "font-size: 14px; font-weight: 500; color: {theme.tokens.read().colors.foreground.to_rgba()};",
249 "{label}"
250 }
251 }
252
253 div {
254 style: "position: relative;",
255
256 button {
257 type: "button",
258 class: "time-picker-trigger",
259 style: "{trigger_style} border-color: {border_color}; {disabled_style}",
260 disabled: props.disabled,
261 onclick: move |_| if !props.disabled { is_open.toggle() },
262
263 if let Some(value) = display_value.clone() {
264 span { "{value}" }
265 } else {
266 span {
267 style: "color: {theme.tokens.read().colors.muted.to_rgba()};",
268 "{placeholder_text}"
269 }
270 }
271
272 div {
273 style: "display: flex; align-items: center; gap: 8px;",
274
275 if props.value.is_some() {
276 button {
277 type: "button",
278 style: "background: none; border: none; cursor: pointer; font-size: 12px; color: {theme.tokens.read().colors.muted.to_rgba()}; padding: 2px; display: flex; align-items: center; justify-content: center;",
279 onclick: on_clear,
280 "✕"
281 }
282 }
283
284 span {
285 style: "font-size: 14px; color: {theme.tokens.read().colors.muted.to_rgba()}; transition: transform 0.2s;",
286 style: if is_open() { "transform: rotate(180deg);" } else { "" },
287 "▼"
288 }
289 }
290 }
291
292 if is_open() && !props.disabled {
293 TimePickerDropdown {
294 value: current_time.clone(),
295 use_24h: props.use_24h,
296 show_seconds: props.show_seconds,
297 minute_step: props.minute_step,
298 on_select: on_select_time,
299 on_close: move || is_open.set(false),
300 }
301 }
302 }
303
304 if let Some(error) = props.error.clone() {
305 span {
306 class: "time-picker-error",
307 style: "font-size: 12px; color: {theme.tokens.read().colors.destructive.to_rgba()};",
308 "{error}"
309 }
310 }
311 }
312 }
313}
314
315#[derive(Props, Clone, PartialEq)]
317struct TimePickerDropdownProps {
318 value: Option<TimeValue>,
319 use_24h: bool,
320 show_seconds: bool,
321 minute_step: u32,
322 on_select: EventHandler<TimeValue>,
323 on_close: EventHandler<()>,
324}
325
326#[component]
327fn TimePickerDropdown(props: TimePickerDropdownProps) -> Element {
328 let theme = use_theme();
329
330 let initial = props.value.clone().unwrap_or_else(|| TimeValue {
332 hour: if props.use_24h { 12 } else { 12 },
333 minute: 0,
334 second: 0,
335 is_pm: false,
336 });
337
338 let mut hour = use_signal(|| initial.hour);
339 let mut minute = use_signal(|| initial.minute);
340 let mut second = use_signal(|| initial.second);
341 let mut is_pm = use_signal(|| initial.is_pm);
342
343 use_effect(move || {
345 if let Some(v) = &props.value {
346 hour.set(v.hour);
347 minute.set(v.minute);
348 second.set(v.second);
349 is_pm.set(v.is_pm);
350 }
351 });
352
353 let dropdown_style = use_style(|t| {
354 Style::new()
355 .absolute()
356 .top("calc(100% + 4px)")
357 .left("0")
358 .rounded(&t.radius, "md")
359 .border(1, &t.colors.border)
360 .bg(&t.colors.popover)
361 .shadow(&t.shadows.lg)
362 .z_index(9999)
363 .p_px(12)
364 .build()
365 });
366
367 let columns_container_style = use_style(|_| Style::new().flex().gap_px(8).build());
368
369 let column_style = use_style(|_| {
370 Style::new()
371 .flex()
372 .flex_col()
373 .items_center()
374 .gap_px(4)
375 .build()
376 });
377
378 let header_style = use_style(|t| {
379 Style::new()
380 .font_size(11)
381 .font_weight(600)
382 .text_color(&t.colors.muted)
383 .pb_px(4)
384 .build()
385 });
386
387 let scroll_container_style = use_style(|t| {
388 Style::new()
389 .h_px(200)
390 .overflow_auto()
391 .flex()
392 .flex_col()
393 .gap_px(2)
394 .build()
395 + " scrollbar-width: thin; &::-webkit-scrollbar { width: 4px; } &::-webkit-scrollbar-thumb { background: "
396 + &t.colors.border.to_rgba()
397 + "; border-radius: 2px; }"
398 });
399
400 let hour_options: Vec<u32> = if props.use_24h {
402 (0..24).collect()
403 } else {
404 (1..13).collect()
405 };
406
407 let minute_options: Vec<u32> = (0..60).step_by(props.minute_step as usize).collect();
409
410 let second_options: Vec<u32> = (0..60).step_by(5).collect();
412
413 let on_select = {
414 let on_select = props.on_select.clone();
415 move || {
416 on_select.call(TimeValue {
417 hour: hour(),
418 minute: minute(),
419 second: second(),
420 is_pm: is_pm(),
421 });
422 }
423 };
424
425 let on_now = {
426 let mut hour = hour.clone();
427 let mut minute = minute.clone();
428 let mut second = second.clone();
429 let mut is_pm = is_pm.clone();
430 let on_select = props.on_select.clone();
431 let use_24h = props.use_24h;
432 move || {
433 let now = TimeValue::now(use_24h);
434 hour.set(now.hour);
435 minute.set(now.minute);
436 second.set(now.second);
437 is_pm.set(now.is_pm);
438 on_select.call(now);
439 }
440 };
441
442 rsx! {
443 div {
445 class: "time-picker-backdrop",
446 style: "position: fixed; inset: 0; z-index: 9998;",
447 onclick: move |_| props.on_close.call(()),
448 }
449
450 div {
451 class: "time-picker-dropdown",
452 style: "{dropdown_style}",
453 onclick: move |e: Event<MouseData>| e.stop_propagation(),
454
455 div {
457 style: "{columns_container_style}",
458
459 div {
461 style: "{column_style}",
462
463 span {
464 style: "{header_style}",
465 "HH"
466 }
467
468 div {
469 style: "{scroll_container_style}",
470
471 for h in hour_options {
472 {
473 let is_selected = hour() == h;
474 let bg_color = if is_selected {
475 theme.tokens.read().colors.primary.to_rgba()
476 } else {
477 "transparent".to_string()
478 };
479 let text_color = if is_selected {
480 "white".to_string()
481 } else {
482 theme.tokens.read().colors.foreground.to_rgba()
483 };
484
485 rsx! {
486 button {
487 key: "hour-{h}",
488 type: "button",
489 style: "width: 48px; height: 32px; border: none; border-radius: 6px; background: {bg_color}; color: {text_color}; font-size: 14px; cursor: pointer; transition: all 100ms ease;",
490 onclick: move |_| {
491 hour.set(h);
492 on_select();
493 },
494 "{h:02}"
495 }
496 }
497 }
498 }
499 }
500 }
501
502 div {
504 style: "display: flex; flex-direction: column; justify-content: center; padding-top: 20px;",
505 span {
506 style: "font-size: 14px; font-weight: 600; color: {theme.tokens.read().colors.muted.to_rgba()};",
507 ":"
508 }
509 }
510
511 div {
513 style: "{column_style}",
514
515 span {
516 style: "{header_style}",
517 "MM"
518 }
519
520 div {
521 style: "{scroll_container_style}",
522
523 for m in minute_options {
524 {
525 let is_selected = minute() == m;
526 let bg_color = if is_selected {
527 theme.tokens.read().colors.primary.to_rgba()
528 } else {
529 "transparent".to_string()
530 };
531 let text_color = if is_selected {
532 "white".to_string()
533 } else {
534 theme.tokens.read().colors.foreground.to_rgba()
535 };
536
537 rsx! {
538 button {
539 key: "minute-{m}",
540 type: "button",
541 style: "width: 48px; height: 32px; border: none; border-radius: 6px; background: {bg_color}; color: {text_color}; font-size: 14px; cursor: pointer; transition: all 100ms ease;",
542 onclick: move |_| {
543 minute.set(m);
544 on_select();
545 },
546 "{m:02}"
547 }
548 }
549 }
550 }
551 }
552 }
553
554 if props.show_seconds {
556 div {
558 style: "display: flex; flex-direction: column; justify-content: center; padding-top: 20px;",
559 span {
560 style: "font-size: 14px; font-weight: 600; color: {theme.tokens.read().colors.muted.to_rgba()};",
561 ":"
562 }
563 }
564
565 div {
566 style: "{column_style}",
567
568 span {
569 style: "{header_style}",
570 "SS"
571 }
572
573 div {
574 style: "{scroll_container_style}",
575
576 for s in second_options {
577 {
578 let is_selected = second() == s;
579 let bg_color = if is_selected {
580 theme.tokens.read().colors.primary.to_rgba()
581 } else {
582 "transparent".to_string()
583 };
584 let text_color = if is_selected {
585 "white".to_string()
586 } else {
587 theme.tokens.read().colors.foreground.to_rgba()
588 };
589
590 rsx! {
591 button {
592 key: "second-{s}",
593 type: "button",
594 style: "width: 48px; height: 32px; border: none; border-radius: 6px; background: {bg_color}; color: {text_color}; font-size: 14px; cursor: pointer; transition: all 100ms ease;",
595 onclick: move |_| {
596 second.set(s);
597 on_select();
598 },
599 "{s:02}"
600 }
601 }
602 }
603 }
604 }
605 }
606 }
607
608 if !props.use_24h {
610 div {
611 style: "{column_style} margin-left: 4px;",
612
613 span {
614 style: "{header_style}",
615 ""
616 }
617
618 div {
619 style: "{scroll_container_style}",
620
621 for (label, value) in [("AM", false), ("PM", true)] {
622 {
623 let is_selected = is_pm() == value;
624 let bg_color = if is_selected {
625 theme.tokens.read().colors.primary.to_rgba()
626 } else {
627 "transparent".to_string()
628 };
629 let text_color = if is_selected {
630 "white".to_string()
631 } else {
632 theme.tokens.read().colors.foreground.to_rgba()
633 };
634
635 rsx! {
636 button {
637 key: "ampm-{label}",
638 type: "button",
639 style: "width: 48px; height: 32px; border: none; border-radius: 6px; background: {bg_color}; color: {text_color}; font-size: 12px; cursor: pointer; transition: all 100ms ease; font-weight: 500;",
640 onclick: move |_| {
641 is_pm.set(value);
642 on_select();
643 },
644 "{label}"
645 }
646 }
647 }
648 }
649 }
650 }
651 }
652 }
653
654 TimePickerNowButton {
656 on_click: on_now,
657 }
658 }
659 }
660}
661
662#[derive(Props, Clone, PartialEq)]
664struct TimePickerNowButtonProps {
665 on_click: EventHandler<()>,
666}
667
668#[component]
669fn TimePickerNowButton(props: TimePickerNowButtonProps) -> Element {
670 let theme = use_theme();
671 let mut is_hovered = use_signal(|| false);
672
673 let bg_color = if is_hovered() {
674 theme.tokens.read().colors.muted.to_rgba()
675 } else {
676 "transparent".to_string()
677 };
678
679 rsx! {
680 div {
681 style: "display: flex; justify-content: center; margin-top: 12px; padding-top: 12px; border-top: 1px solid {theme.tokens.read().colors.border.to_rgba()};",
682
683 button {
684 type: "button",
685 style: "font-size: 13px; font-weight: 500; color: {theme.tokens.read().colors.primary.to_rgba()}; background: {bg_color}; border: none; cursor: pointer; padding: 6px 12px; border-radius: 6px; transition: all 100ms ease;",
686 onmouseenter: move |_| is_hovered.set(true),
687 onmouseleave: move |_| is_hovered.set(false),
688 onclick: move |_| props.on_click.call(()),
689 "Now"
690 }
691 }
692 }
693}
694
695#[derive(Props, Clone, PartialEq)]
697pub struct TimeInputProps {
698 #[props(default)]
700 pub value: Option<String>,
701 pub on_change: EventHandler<Option<String>>,
703 #[props(default = true)]
705 pub use_24h: bool,
706 #[props(default = false)]
708 pub show_seconds: bool,
709 #[props(default = false)]
711 pub disabled: bool,
712 #[props(default)]
714 pub placeholder: Option<String>,
715 #[props(default)]
717 pub class: Option<String>,
718}
719
720#[component]
722pub fn TimeInput(props: TimeInputProps) -> Element {
723 let _theme = use_theme();
724 let mut input_value = use_signal(|| props.value.clone().unwrap_or_default());
725
726 use_effect(move || {
728 input_value.set(props.value.clone().unwrap_or_default());
729 });
730
731 let class_css = props
732 .class
733 .as_ref()
734 .map(|c| format!(" {}", c))
735 .unwrap_or_default();
736
737 let input_style = use_style(move |t| {
738 Style::new()
739 .w_full()
740 .h_px(40)
741 .px(&t.spacing, "md")
742 .rounded(&t.radius, "md")
743 .border(1, &t.colors.border)
744 .bg(&t.colors.background)
745 .text_color(&t.colors.foreground)
746 .font_size(14)
747 .cursor(if props.disabled {
748 "not-allowed"
749 } else {
750 "text"
751 })
752 .transition("all 150ms ease")
753 .outline("none")
754 .build()
755 });
756
757 let handle_input = move |e: Event<FormData>| {
758 let value = e.value();
759
760 let filtered: String = value
762 .chars()
763 .filter(|c| c.is_ascii_digit() || *c == ':')
764 .collect();
765
766 let formatted = format_time_input(&filtered, props.show_seconds);
768 input_value.set(formatted.clone());
769
770 if is_valid_time(&formatted, props.show_seconds) {
772 props.on_change.call(Some(formatted));
773 }
774 };
775
776 let placeholder = if props.show_seconds {
777 props
778 .placeholder
779 .clone()
780 .unwrap_or_else(|| "HH:MM:SS".to_string())
781 } else {
782 props
783 .placeholder
784 .clone()
785 .unwrap_or_else(|| "HH:MM".to_string())
786 };
787
788 rsx! {
789 input {
790 r#type: "text",
791 class: "time-input{class_css}",
792 style: "{input_style}",
793 placeholder: "{placeholder}",
794 value: "{input_value}",
795 disabled: props.disabled,
796 oninput: handle_input,
797 }
798 }
799}
800
801fn format_time_input(input: &str, show_seconds: bool) -> String {
803 let digits: String = input.chars().filter(|c| c.is_ascii_digit()).collect();
804
805 if show_seconds {
806 match digits.len() {
807 0..=2 => digits,
808 3..=4 => format!("{}:{}", &digits[..2], &digits[2..]),
809 _ => format!(
810 "{}:{}:{}",
811 &digits[..2],
812 &digits[2..4],
813 &digits[4..6.min(digits.len())]
814 ),
815 }
816 } else {
817 match digits.len() {
818 0..=2 => digits,
819 _ => format!("{}:{}", &digits[..2], &digits[2..4.min(digits.len())]),
820 }
821 }
822}
823
824fn is_valid_time(input: &str, show_seconds: bool) -> bool {
826 let parts: Vec<&str> = input.split(':').collect();
827
828 if show_seconds {
829 if parts.len() != 3 {
830 return false;
831 }
832 if let (Ok(h), Ok(m), Ok(s)) = (
833 parts[0].parse::<u32>(),
834 parts[1].parse::<u32>(),
835 parts[2].parse::<u32>(),
836 ) {
837 h <= 23 && m <= 59 && s <= 59
838 } else {
839 false
840 }
841 } else {
842 if parts.len() != 2 {
843 return false;
844 }
845 if let (Ok(h), Ok(m)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
846 h <= 23 && m <= 59
847 } else {
848 false
849 }
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856
857 #[test]
858 fn test_time_value_parse() {
859 let time = TimeValue::from_string("14:30", true).unwrap();
860 assert_eq!(time.hour, 14);
861 assert_eq!(time.minute, 30);
862 assert_eq!(time.second, 0);
863 assert!(!time.is_pm);
864 }
865
866 #[test]
867 fn test_time_value_parse_12h() {
868 let time = TimeValue::from_string("14:30", false).unwrap();
869 assert_eq!(time.hour, 2);
870 assert_eq!(time.minute, 30);
871 assert!(time.is_pm);
872 }
873
874 #[test]
875 fn test_time_value_to_string() {
876 let time = TimeValue {
877 hour: 2,
878 minute: 30,
879 second: 0,
880 is_pm: true,
881 };
882 assert_eq!(time.to_string(true, false), "14:30");
883 assert_eq!(time.to_string(false, false), "02:30");
884 }
885
886 #[test]
887 fn test_format_time_input() {
888 assert_eq!(format_time_input("123", false), "12:3");
889 assert_eq!(format_time_input("1234", false), "12:34");
890 assert_eq!(format_time_input("123456", true), "12:34:56");
891 }
892
893 #[test]
894 fn test_is_valid_time() {
895 assert!(is_valid_time("12:30", false));
896 assert!(is_valid_time("23:59", false));
897 assert!(!is_valid_time("24:00", false));
898 assert!(!is_valid_time("12:60", false));
899 assert!(is_valid_time("12:30:45", true));
900 assert!(!is_valid_time("12:30", true)); }
902}