impulse_thaw/time_picker/
mod.rs

1mod rule;
2mod types;
3
4pub use rule::*;
5pub use types::*;
6
7use crate::{
8    Button, ButtonSize, FieldInjection, Icon, Input, InputSuffix, Rule, Scrollbar,
9    ScrollbarRef,
10};
11use chrono::{Local, NaiveTime, Timelike};
12use leptos::{html, prelude::*};
13use thaw_components::{Follower, FollowerPlacement};
14use thaw_utils::{
15    class_list, mount_style, ArcOneCallback, ComponentRef, OptionModel, OptionModelWithValue,
16    SignalWatch,
17};
18
19#[component]
20pub fn TimePicker(
21    #[prop(optional, into)] class: MaybeProp<String>,
22    #[prop(optional, into)] id: MaybeProp<String>,
23    /// A string specifying a name for the input control.
24    /// This name is submitted along with the control's value when the form data is submitted.
25    #[prop(optional, into)]
26    name: MaybeProp<String>,
27    /// The rules to validate Field.
28    #[prop(optional, into)]
29    rules: Vec<TimePickerRule>,
30    /// Set the TimePicker value.
31    #[prop(optional, into)]
32    value: OptionModel<NaiveTime>,
33    /// Size of the input.
34    #[prop(optional, into)]
35    size: Signal<TimePickerSize>,
36) -> impl IntoView {
37    mount_style("time-picker", include_str!("./time-picker.css"));
38    let (id, name) = FieldInjection::use_id_and_name(id, name);
39    let validate = Rule::validate(rules, value, name);
40    let time_picker_ref = NodeRef::<html::Div>::new();
41    let panel_ref = ComponentRef::<PanelRef>::default();
42    let is_show_panel = RwSignal::new(false);
43    let show_time_format = "%H:%M:%S";
44    let show_time_text = RwSignal::new(String::new());
45    let update_show_time_text = move || {
46        value.with_untracked(move |time| {
47            let text = match time {
48                OptionModelWithValue::T(v) => v.format(show_time_format).to_string(),
49                OptionModelWithValue::Option(v) => v.map_or(String::new(), |time| {
50                    time.format(show_time_format).to_string()
51                }),
52            };
53
54            show_time_text.set(text);
55        });
56    };
57    update_show_time_text();
58    let panel_selected_time = RwSignal::new(None::<NaiveTime>);
59    _ = panel_selected_time.watch(move |time| {
60        let text = time.as_ref().map_or(String::new(), |time| {
61            time.format(show_time_format).to_string()
62        });
63        show_time_text.set(text);
64    });
65
66    let on_input_blur = move |_| {
67        if let Ok(time) =
68            NaiveTime::parse_from_str(&show_time_text.get_untracked(), show_time_format)
69        {
70            if value.get_untracked() != Some(time) {
71                value.set(Some(time));
72                update_show_time_text();
73            }
74        } else {
75            update_show_time_text();
76        }
77        validate.run(Some(TimePickerRuleTrigger::Blur));
78    };
79    let close_panel = move |time: Option<NaiveTime>| {
80        if value.get_untracked() != time {
81            if time.is_some() {
82                value.set(time);
83            }
84            update_show_time_text();
85        }
86        is_show_panel.set(false);
87    };
88
89    let open_panel = move || {
90        if is_show_panel.get() {
91            return;
92        }
93        panel_selected_time.set(value.get_untracked());
94        is_show_panel.set(true);
95        request_animation_frame(move || {
96            if let Some(panel_ref) = panel_ref.get_untracked() {
97                panel_ref.scroll_into_view();
98            }
99        });
100    };
101
102    view! {
103        <crate::_binder::Binder>
104            <div
105                node_ref=time_picker_ref
106                class=class_list!["thaw-time-picker", class]
107                on:click=move |_| open_panel()
108            >
109                <Input
110                    id
111                    name
112                    value=show_time_text
113                    on_focus=move |_| open_panel()
114                    on_blur=on_input_blur
115                    size=Signal::derive(move || size.get().into())
116                >
117                    <InputSuffix slot>
118                        <Icon icon=icondata_ai::AiClockCircleOutlined style="font-size: 18px" />
119                    </InputSuffix>
120                </Input>
121            </div>
122            <Follower slot show=is_show_panel placement=FollowerPlacement::BottomStart>
123                <Panel
124                    selected_time=panel_selected_time
125                    close_panel
126                    time_picker_ref
127                    comp_ref=panel_ref
128                />
129            </Follower>
130        </crate::_binder::Binder>
131    }
132}
133
134#[component]
135fn Panel(
136    selected_time: RwSignal<Option<NaiveTime>>,
137    time_picker_ref: NodeRef<html::Div>,
138    #[prop(into)] close_panel: ArcOneCallback<Option<NaiveTime>>,
139    comp_ref: ComponentRef<PanelRef>,
140) -> impl IntoView {
141    let now = {
142        let close_panel = close_panel.clone();
143        move |_| {
144            close_panel(Some(now_time()));
145        }
146    };
147    let ok = {
148        let close_panel = close_panel.clone();
149        move |_| {
150            close_panel(selected_time.get_untracked());
151        }
152    };
153
154    let panel_ref = NodeRef::<html::Div>::new();
155    #[cfg(any(feature = "csr", feature = "hydrate"))]
156    {
157        use leptos::wasm_bindgen::__rt::IntoJsResult;
158        let handle = window_event_listener(leptos::ev::click, move |ev| {
159            let el = ev.target();
160            let mut el: Option<web_sys::Element> =
161                el.into_js_result().map_or(None, |el| Some(el.into()));
162            let body = document().body().unwrap();
163            while let Some(current_el) = el {
164                if current_el == *body {
165                    break;
166                };
167                let Some(panel_el) = panel_ref.get() else {
168                    return;
169                };
170                let time_picker_el = time_picker_ref.get().unwrap();
171                if current_el == **panel_el || current_el == **time_picker_el {
172                    return;
173                }
174                el = current_el.parent_element();
175            }
176            close_panel(None);
177        });
178        on_cleanup(move || handle.remove());
179    }
180    #[cfg(not(any(feature = "csr", feature = "hydrate")))]
181    {
182        _ = time_picker_ref;
183        _ = panel_ref;
184    }
185
186    let hour_ref = ComponentRef::<ScrollbarRef>::new();
187    let minute_ref = ComponentRef::<ScrollbarRef>::new();
188    let second_ref = ComponentRef::<ScrollbarRef>::new();
189    comp_ref.load(PanelRef {
190        hour_ref,
191        minute_ref,
192        second_ref,
193    });
194
195    view! {
196        <div class="thaw-time-picker-panel" node_ref=panel_ref on:mousedown=|e| e.prevent_default()>
197            <div class="thaw-time-picker-panel__time">
198                <div class="thaw-time-picker-panel__time-hour">
199                    <Scrollbar size=6 comp_ref=hour_ref>
200                        {(0..24)
201                            .map(|hour| {
202                                let comp_ref = ComponentRef::<PanelTimeItemRef>::default();
203                                let on_click = move |_| {
204                                    selected_time
205                                        .update(move |time| {
206                                            *time = if let Some(time) = time {
207                                                time.with_hour(hour)
208                                            } else {
209                                                NaiveTime::from_hms_opt(hour, 0, 0)
210                                            }
211                                        });
212                                    comp_ref.get_untracked().unwrap().scroll_into_view();
213                                };
214                                let is_selected = Memo::new(move |_| {
215                                    selected_time.get().map_or(false, |v| v.hour() == hour)
216                                });
217                                view! {
218                                    <PanelTimeItem
219                                        value=hour
220                                        on:click=on_click
221                                        is_selected
222                                        comp_ref
223                                    />
224                                }
225                            })
226                            .collect_view()}
227                        <div class="thaw-time-picker-panel__time-padding"></div>
228                    </Scrollbar>
229                </div>
230                <div class="thaw-time-picker-panel__time-minute">
231                    <Scrollbar size=6 comp_ref=minute_ref>
232                        {(0..60)
233                            .map(|minute| {
234                                let comp_ref = ComponentRef::<PanelTimeItemRef>::default();
235                                let on_click = move |_| {
236                                    selected_time
237                                        .update(move |time| {
238                                            *time = if let Some(time) = time {
239                                                time.with_minute(minute)
240                                            } else {
241                                                NaiveTime::from_hms_opt(now_time().hour(), minute, 0)
242                                            }
243                                        });
244                                    comp_ref.get_untracked().unwrap().scroll_into_view();
245                                };
246                                let is_selected = Memo::new(move |_| {
247                                    selected_time.get().map_or(false, |v| v.minute() == minute)
248                                });
249                                view! {
250                                    <PanelTimeItem
251                                        value=minute
252                                        on:click=on_click
253                                        is_selected
254                                        comp_ref
255                                    />
256                                }
257                            })
258                            .collect_view()}
259                        <div class="thaw-time-picker-panel__time-padding"></div>
260                    </Scrollbar>
261                </div>
262                <div class="thaw-time-picker-panel__time-second">
263                    <Scrollbar size=6 comp_ref=second_ref>
264                        {(0..60)
265                            .map(|second| {
266                                let comp_ref = ComponentRef::<PanelTimeItemRef>::default();
267                                let on_click = move |_| {
268                                    selected_time
269                                        .update(move |time| {
270                                            *time = if let Some(time) = time {
271                                                time.with_second(second)
272                                            } else {
273                                                now_time().with_second(second)
274                                            }
275                                        });
276                                    comp_ref.get_untracked().unwrap().scroll_into_view();
277                                };
278                                let is_selected = Memo::new(move |_| {
279                                    selected_time.get().map_or(false, |v| v.second() == second)
280                                });
281                                view! {
282                                    <PanelTimeItem
283                                        value=second
284                                        on:click=on_click
285                                        is_selected
286                                        comp_ref
287                                    />
288                                }
289                            })
290                            .collect_view()}
291                        <div class="thaw-time-picker-panel__time-padding"></div>
292                    </Scrollbar>
293                </div>
294            </div>
295            <div class="thaw-time-picker-panel__footer">
296                <Button size=ButtonSize::Small on_click=now>
297                    "Now"
298                </Button>
299                <Button size=ButtonSize::Small on_click=ok>
300                    "OK"
301                </Button>
302            </div>
303        </div>
304    }
305}
306
307#[derive(Clone)]
308struct PanelRef {
309    hour_ref: ComponentRef<ScrollbarRef>,
310    minute_ref: ComponentRef<ScrollbarRef>,
311    second_ref: ComponentRef<ScrollbarRef>,
312}
313
314impl PanelRef {
315    fn scroll_top(scrollbar_ref: ScrollbarRef) {
316        let Some(contetn_ref) = scrollbar_ref.content_ref.get_untracked() else {
317            return;
318        };
319        let Ok(Some(slected_el)) =
320            contetn_ref.query_selector(".thaw-time-picker-panel__time-item--slected")
321        else {
322            return;
323        };
324        use wasm_bindgen::JsCast;
325        if let Ok(slected_el) = slected_el.dyn_into::<web_sys::HtmlElement>() {
326            let options = web_sys::ScrollToOptions::new();
327            options.set_top(f64::from(slected_el.offset_top()));
328            scrollbar_ref.scroll_to_with_scroll_to_options(&options);
329        }
330    }
331
332    fn scroll_into_view(&self) {
333        if let Some(hour) = self.hour_ref.get_untracked() {
334            Self::scroll_top(hour);
335        }
336        if let Some(minute) = self.minute_ref.get_untracked() {
337            Self::scroll_top(minute);
338        }
339        if let Some(second) = self.second_ref.get_untracked() {
340            Self::scroll_top(second);
341        }
342    }
343}
344
345#[component]
346fn PanelTimeItem(
347    value: u32,
348    is_selected: Memo<bool>,
349    comp_ref: ComponentRef<PanelTimeItemRef>,
350) -> impl IntoView {
351    let item_ref = NodeRef::new();
352    comp_ref.load(PanelTimeItemRef { item_ref });
353
354    view! {
355        <div
356            class="thaw-time-picker-panel__time-item"
357            class=("thaw-time-picker-panel__time-item--slected", move || is_selected.get())
358            node_ref=item_ref
359        >
360
361            {format!("{value:02}")}
362
363        </div>
364    }
365}
366
367#[derive(Clone)]
368struct PanelTimeItemRef {
369    item_ref: NodeRef<html::Div>,
370}
371
372impl PanelTimeItemRef {
373    fn scroll_into_view(&self) {
374        if let Some(item_ref) = self.item_ref.get_untracked() {
375            item_ref.scroll_into_view_with_bool(true);
376        }
377    }
378}
379
380fn now_time() -> NaiveTime {
381    Local::now().time()
382}