impulse_thaw/time_picker/
mod.rs1mod 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 #[prop(optional, into)]
26 name: MaybeProp<String>,
27 #[prop(optional, into)]
29 rules: Vec<TimePickerRule>,
30 #[prop(optional, into)]
32 value: OptionModel<NaiveTime>,
33 #[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}