1use crate::components::config_provider::{Locale, use_config};
2use crate::components::floating::use_floating_close_handle;
3use crate::components::select_base::use_dropdown_layer;
4use dioxus::events::KeyboardEvent;
5use dioxus::prelude::*;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct TimeValue {
10 pub hour: u8,
11 pub minute: u8,
12 pub second: u8,
13}
14
15impl TimeValue {
16 pub fn new(hour: u8, minute: u8, second: u8) -> Self {
17 Self {
18 hour,
19 minute,
20 second,
21 }
22 }
23
24 pub fn normalised(hour: i32, minute: i32, second: i32) -> Self {
26 let h = hour.clamp(0, 23) as u8;
27 let m = minute.clamp(0, 59) as u8;
28 let s = second.clamp(0, 59) as u8;
29 TimeValue {
30 hour: h,
31 minute: m,
32 second: s,
33 }
34 }
35
36 pub fn to_hms_string(&self) -> String {
38 format!("{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
39 }
40}
41
42#[derive(Props, Clone, PartialEq)]
44pub struct TimePickerProps {
45 #[props(optional)]
47 pub value: Option<TimeValue>,
48 #[props(optional)]
50 pub default_value: Option<TimeValue>,
51 #[props(optional)]
53 pub placeholder: Option<String>,
54 #[props(optional)]
56 pub format: Option<String>,
57 #[props(optional)]
59 pub hour_step: Option<u8>,
60 #[props(optional)]
62 pub minute_step: Option<u8>,
63 #[props(optional)]
65 pub second_step: Option<u8>,
66 #[props(optional)]
68 pub disabled: Option<bool>,
69 #[props(optional)]
71 pub allow_clear: Option<bool>,
72 #[props(optional)]
74 pub class: Option<String>,
75 #[props(optional)]
77 pub style: Option<String>,
78 #[props(optional)]
80 pub on_change: Option<EventHandler<Option<TimeValue>>>,
81}
82
83#[component]
85pub fn TimePicker(props: TimePickerProps) -> Element {
86 let TimePickerProps {
87 value,
88 default_value,
89 placeholder,
90 format: _format,
91 hour_step,
92 minute_step,
93 second_step,
94 disabled,
95 allow_clear,
96 class,
97 style,
98 on_change,
99 } = props;
100
101 let config = use_config();
102 let locale = config.locale;
103
104 let is_disabled = disabled.unwrap_or(false);
105 let allow_clear_flag = allow_clear.unwrap_or(false);
106
107 let step_h = hour_step.unwrap_or(1).max(1);
108 let step_m = minute_step.unwrap_or(1).max(1);
109 let step_s = second_step.unwrap_or(1).max(1);
110
111 let initial_inner = default_value.unwrap_or(TimeValue::new(0, 0, 0));
112 let inner_state: Signal<TimeValue> = use_signal(|| initial_inner);
113
114 let current_value = if let Some(v) = value {
115 v
116 } else {
117 *inner_state.read()
118 };
119 let display_text = if value.is_some() || default_value.is_some() {
120 current_value.to_hms_string()
121 } else {
122 String::new()
123 };
124
125 let default_placeholder = match locale {
126 Locale::ZhCN => "请选择时间".to_string(),
127 Locale::EnUS => "Select time".to_string(),
128 };
129 let placeholder_str = placeholder.unwrap_or(default_placeholder);
130
131 let controlled = value.is_some();
132
133 let TimeValue {
134 hour: current_hour,
135 minute: current_minute,
136 second: current_second,
137 } = current_value;
138
139 let open_state: Signal<bool> = use_signal(|| false);
141 let open_flag = *open_state.read();
142 let close_handle = use_floating_close_handle(open_state);
143 let dropdown_layer = use_dropdown_layer(open_flag);
144 let current_z = *dropdown_layer.z_index.read();
145
146 let mut control_classes = vec!["adui-time-picker".to_string()];
147 if is_disabled {
148 control_classes.push("adui-time-picker-disabled".to_string());
149 }
150 if let Some(extra) = class.clone() {
151 control_classes.push(extra);
152 }
153 let control_class_attr = control_classes.join(" ");
154 let style_attr = style.unwrap_or_default();
155
156 let hours: Vec<u8> = (0..24).step_by(step_h as usize).collect();
158 let minutes: Vec<u8> = (0..60).step_by(step_m as usize).collect();
159 let seconds: Vec<u8> = (0..60).step_by(step_s as usize).collect();
160
161 let hour_cells: Vec<(u8, String, String)> = hours
163 .iter()
164 .map(|&h| {
165 let mut classes = vec!["adui-time-picker-cell".to_string()];
166 if current_hour == h {
167 classes.push("adui-time-picker-cell-active".to_string());
168 }
169 let class_attr = classes.join(" ");
170 let label = format!("{:02}", h);
171 (h, class_attr, label)
172 })
173 .collect();
174 let minute_cells: Vec<(u8, String, String)> = minutes
175 .iter()
176 .map(|&m| {
177 let mut classes = vec!["adui-time-picker-cell".to_string()];
178 if current_minute == m {
179 classes.push("adui-time-picker-cell-active".to_string());
180 }
181 let class_attr = classes.join(" ");
182 let label = format!("{:02}", m);
183 (m, class_attr, label)
184 })
185 .collect();
186 let second_cells: Vec<(u8, String, String)> = seconds
187 .iter()
188 .map(|&s| {
189 let mut classes = vec!["adui-time-picker-cell".to_string()];
190 if current_second == s {
191 classes.push("adui-time-picker-cell-active".to_string());
192 }
193 let class_attr = classes.join(" ");
194 let label = format!("{:02}", s);
195 (s, class_attr, label)
196 })
197 .collect();
198
199 let on_change_cb = on_change;
201 let inner_for_change = inner_state;
202 let apply_time = move |next: TimeValue| {
203 if controlled {
204 if let Some(cb) = on_change_cb {
205 cb.call(Some(next));
206 }
207 } else {
208 let mut state = inner_for_change;
209 state.set(next);
210 if let Some(cb) = on_change_cb {
211 cb.call(Some(next));
212 }
213 }
214 };
215
216 rsx! {
217 div {
218 class: "adui-time-picker-root",
219 style: "position: relative; display: inline-block;",
220 div {
221 class: "{control_class_attr}",
222 style: "{style_attr}",
223 role: "combobox",
224 tabindex: (!is_disabled).then_some(0),
225 "aria-expanded": open_flag,
226 "aria-disabled": is_disabled,
227 onclick: move |_| {
228 if is_disabled { return; }
229 close_handle.mark_internal_click();
230 let mut open_signal = open_state;
231 let next = !*open_signal.read();
232 open_signal.set(next);
233 },
234 onkeydown: move |evt: KeyboardEvent| {
235 if is_disabled { return; }
236 use dioxus::prelude::Key;
237 match evt.key() {
238 Key::Enter => {
239 evt.prevent_default();
240 let mut open_signal = open_state;
241 open_signal.set(true);
242 }
243 Key::Escape => {
244 close_handle.close();
245 }
246 _ => {}
247 }
248 },
249 input {
250 class: "adui-time-picker-input",
251 readonly: true,
252 disabled: is_disabled,
253 value: "{display_text}",
254 placeholder: "{placeholder_str}",
255 }
256 if allow_clear_flag && !display_text.is_empty() && !is_disabled {
257 span {
258 class: "adui-time-picker-clear",
259 onclick: move |_| {
260 close_handle.mark_internal_click();
261 if controlled {
262 if let Some(cb) = on_change {
263 cb.call(None);
264 }
265 } else {
266 let mut state = inner_state;
267 state.set(TimeValue::new(0, 0, 0));
268 if let Some(cb) = on_change {
269 cb.call(None);
270 }
271 }
272 },
273 "×"
274 }
275 }
276 }
277
278 if open_flag {
279 div {
280 class: "adui-time-picker-dropdown",
281 style: "position: absolute; top: 100%; left: 0; min-width: 100%; z-index: {current_z};",
282 div { class: "adui-time-picker-panel",
283 div { class: "adui-time-picker-column",
285 for (h, class_attr, label) in hour_cells {
286 span {
287 class: "{class_attr}",
288 onclick: move |_| {
289 close_handle.mark_internal_click();
290 let next = TimeValue::new(h, current_minute, current_second);
291 apply_time(next);
292 },
293 "{label}"
294 }
295 }
296 }
297 div { class: "adui-time-picker-column",
299 for (m, class_attr, label) in minute_cells {
300 span {
301 class: "{class_attr}",
302 onclick: move |_| {
303 close_handle.mark_internal_click();
304 let next = TimeValue::new(current_hour, m, current_second);
305 apply_time(next);
306 },
307 "{label}"
308 }
309 }
310 }
311 div { class: "adui-time-picker-column",
313 for (s, class_attr, label) in second_cells {
314 span {
315 class: "{class_attr}",
316 onclick: move |_| {
317 close_handle.mark_internal_click();
318 let next = TimeValue::new(current_hour, current_minute, s);
319 apply_time(next);
320 close_handle.close();
322 },
323 "{label}"
324 }
325 }
326 }
327 }
328 }
329 }
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn time_value_to_string_roundtrip() {
340 let v = TimeValue::new(9, 5, 7);
341 assert_eq!(v.to_hms_string(), "09:05:07");
342 }
343
344 #[test]
345 fn time_value_new() {
346 let v = TimeValue::new(12, 30, 45);
347 assert_eq!(v.hour, 12);
348 assert_eq!(v.minute, 30);
349 assert_eq!(v.second, 45);
350 }
351
352 #[test]
353 fn time_value_to_hms_string_formatting() {
354 assert_eq!(TimeValue::new(0, 0, 0).to_hms_string(), "00:00:00");
355 assert_eq!(TimeValue::new(9, 5, 7).to_hms_string(), "09:05:07");
356 assert_eq!(TimeValue::new(23, 59, 59).to_hms_string(), "23:59:59");
357 assert_eq!(TimeValue::new(1, 2, 3).to_hms_string(), "01:02:03");
358 assert_eq!(TimeValue::new(12, 30, 45).to_hms_string(), "12:30:45");
359 }
360
361 #[test]
362 fn time_value_normalised_valid_range() {
363 let v = TimeValue::normalised(12, 30, 45);
364 assert_eq!(v.hour, 12);
365 assert_eq!(v.minute, 30);
366 assert_eq!(v.second, 45);
367 }
368
369 #[test]
370 fn time_value_normalised_clamps_upper_bound() {
371 let v1 = TimeValue::normalised(25, 30, 45);
372 assert_eq!(v1.hour, 23); let v2 = TimeValue::normalised(12, 70, 45);
375 assert_eq!(v2.minute, 59); let v3 = TimeValue::normalised(12, 30, 100);
378 assert_eq!(v3.second, 59); let v4 = TimeValue::normalised(25, 70, 100);
381 assert_eq!(v4.hour, 23);
382 assert_eq!(v4.minute, 59);
383 assert_eq!(v4.second, 59);
384 }
385
386 #[test]
387 fn time_value_normalised_clamps_lower_bound() {
388 let v1 = TimeValue::normalised(-1, 30, 45);
389 assert_eq!(v1.hour, 0); let v2 = TimeValue::normalised(12, -5, 45);
392 assert_eq!(v2.minute, 0); let v3 = TimeValue::normalised(12, 30, -10);
395 assert_eq!(v3.second, 0); let v4 = TimeValue::normalised(-5, -10, -20);
398 assert_eq!(v4.hour, 0);
399 assert_eq!(v4.minute, 0);
400 assert_eq!(v4.second, 0);
401 }
402
403 #[test]
404 fn time_value_normalised_boundary_values() {
405 assert_eq!(TimeValue::normalised(0, 0, 0).hour, 0);
407 assert_eq!(TimeValue::normalised(23, 59, 59).hour, 23);
408 assert_eq!(TimeValue::normalised(23, 59, 59).minute, 59);
409 assert_eq!(TimeValue::normalised(23, 59, 59).second, 59);
410 }
411
412 #[test]
413 fn time_value_clone_and_copy() {
414 let v1 = TimeValue::new(12, 30, 45);
415 let v2 = v1; assert_eq!(v1, v2);
417 assert_eq!(v1.hour, v2.hour);
418 assert_eq!(v1.minute, v2.minute);
419 assert_eq!(v1.second, v2.second);
420 }
421
422 #[test]
423 fn time_value_partial_eq() {
424 let v1 = TimeValue::new(12, 30, 45);
425 let v2 = TimeValue::new(12, 30, 45);
426 let v3 = TimeValue::new(13, 30, 45);
427
428 assert_eq!(v1, v2);
429 assert_ne!(v1, v3);
430 }
431
432 #[test]
433 fn time_value_debug() {
434 let v = TimeValue::new(12, 30, 45);
435 let debug_str = format!("{:?}", v);
436 assert!(debug_str.contains("TimeValue"));
437 }
438
439 #[test]
440 fn time_picker_props_optional_fields() {
441 let _value: Option<TimeValue> = None;
445 let _default_value: Option<TimeValue> = None;
446 let _placeholder: Option<String> = None;
447 let _format: Option<String> = None;
448 let _hour_step: Option<u8> = None;
449 let _minute_step: Option<u8> = None;
450 let _second_step: Option<u8> = None;
451 let _disabled: Option<bool> = None;
452 let _allow_clear: Option<bool> = None;
453 let _class: Option<String> = None;
454 let _style: Option<String> = None;
455 assert!(true);
457 }
458}