adui_dioxus/components/
time_picker.rs

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/// Internal value type for TimePicker (HH:mm:ss).
8#[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    /// Clamp components to a safe range and normalise into a `TimeValue`.
25    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    /// Format as `HH:mm:ss` text.
37    pub fn to_hms_string(&self) -> String {
38        format!("{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
39    }
40}
41
42/// Props for the TimePicker component (MVP subset).
43#[derive(Props, Clone, PartialEq)]
44pub struct TimePickerProps {
45    /// Controlled time value.
46    #[props(optional)]
47    pub value: Option<TimeValue>,
48    /// Initial value in uncontrolled mode.
49    #[props(optional)]
50    pub default_value: Option<TimeValue>,
51    /// Placeholder shown when no time is selected.
52    #[props(optional)]
53    pub placeholder: Option<String>,
54    /// Display/parse format. MVP: primarily `HH:mm:ss`.
55    #[props(optional)]
56    pub format: Option<String>,
57    /// Step for hour column.
58    #[props(optional)]
59    pub hour_step: Option<u8>,
60    /// Step for minute column.
61    #[props(optional)]
62    pub minute_step: Option<u8>,
63    /// Step for second column.
64    #[props(optional)]
65    pub second_step: Option<u8>,
66    /// Whether the picker is disabled.
67    #[props(optional)]
68    pub disabled: Option<bool>,
69    /// Whether to show a clear icon.
70    #[props(optional)]
71    pub allow_clear: Option<bool>,
72    /// Extra class on the root element.
73    #[props(optional)]
74    pub class: Option<String>,
75    /// Inline style for the root element.
76    #[props(optional)]
77    pub style: Option<String>,
78    /// Change callback.
79    #[props(optional)]
80    pub on_change: Option<EventHandler<Option<TimeValue>>>,
81}
82
83/// Ant Design flavored TimePicker (MVP: HH:mm:ss with simple steps and dropdown).
84#[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    // Dropdown open/close state.
140    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    // Prepare option lists.
157    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    // Decorated cells with active class and zero-padded label.
162    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    // Shared helper to apply a new time value.
200    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                        // Hours column
284                        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                        // Minutes column
298                        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                        // Seconds column
312                        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                                        // 默认在选择秒后关闭面板。
321                                        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); // Clamped to max hour
373
374        let v2 = TimeValue::normalised(12, 70, 45);
375        assert_eq!(v2.minute, 59); // Clamped to max minute
376
377        let v3 = TimeValue::normalised(12, 30, 100);
378        assert_eq!(v3.second, 59); // Clamped to max second
379
380        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); // Clamped to min hour
390
391        let v2 = TimeValue::normalised(12, -5, 45);
392        assert_eq!(v2.minute, 0); // Clamped to min minute
393
394        let v3 = TimeValue::normalised(12, 30, -10);
395        assert_eq!(v3.second, 0); // Clamped to min second
396
397        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        // Test exact boundaries
406        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; // Copy
416        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        // Test that optional fields can be None
442        // Note: TimePickerProps requires children, so we can't create a full instance
443        // But we can verify the structure allows None for optional fields
444        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        // All optional fields can be None
456        assert!(true);
457    }
458}