Skip to main content

euv_ui/component/input/hook/
impl.rs

1use super::*;
2
3/// Implementation of input functionality.
4impl UseEuvInput {
5    /// Creates a click event handler that toggles a boolean signal.
6    ///
7    /// Produces a `NativeEventHandler` that flips the value of the given
8    /// boolean signal on each click. Useful for toggle buttons, visibility
9    /// switches, and drawer open/close patterns.
10    ///
11    /// # Arguments
12    ///
13    /// - `Signal<bool>` - The boolean signal to toggle.
14    ///
15    /// # Returns
16    ///
17    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler that toggles the signal.
18    pub fn use_toggle(signal: Signal<bool>) -> Option<Rc<dyn Fn(Event)>> {
19        Some(Rc::new(move |_: Event| {
20            let current: bool = signal.get();
21            signal.set(!current);
22        }))
23    }
24
25    /// Creates an input event handler that updates a string signal.
26    ///
27    /// # Arguments
28    ///
29    /// - `Signal<String>` - The signal to update with the input value.
30    ///
31    /// # Returns
32    ///
33    /// - `Option<Rc<dyn Fn(Event)>>` - An input handler.
34    pub fn on_input_value(signal: Signal<String>) -> Option<Rc<dyn Fn(Event)>> {
35        Some(Rc::new(move |event: Event| {
36            let value: Option<String> = event.target().and_then(|target: EventTarget| {
37                if let Ok(input) = target.clone().dyn_into::<HtmlInputElement>() {
38                    return Some(input.value());
39                }
40                if let Ok(textarea) = target.clone().dyn_into::<HtmlTextAreaElement>() {
41                    return Some(textarea.value());
42                }
43                if let Ok(select) = target.clone().dyn_into::<HtmlSelectElement>() {
44                    return Some(select.value());
45                }
46                None
47            });
48            if let Some(value) = value {
49                signal.set(value);
50            }
51        }))
52    }
53
54    /// Creates a change event handler that updates a string signal.
55    ///
56    /// # Arguments
57    ///
58    /// - `Signal<String>` - The signal to update with the change value.
59    ///
60    /// # Returns
61    ///
62    /// - `Option<Rc<dyn Fn(Event)>>` - A change handler.
63    pub fn on_change_value(signal: Signal<String>) -> Option<Rc<dyn Fn(Event)>> {
64        Some(Rc::new(move |event: Event| {
65            let value: Option<String> = event.target().and_then(|target: EventTarget| {
66                if let Ok(input) = target.clone().dyn_into::<HtmlInputElement>() {
67                    return Some(input.value());
68                }
69                if let Ok(select) = target.clone().dyn_into::<HtmlSelectElement>() {
70                    return Some(select.value());
71                }
72                if let Ok(textarea) = target.clone().dyn_into::<HtmlTextAreaElement>() {
73                    return Some(textarea.value());
74                }
75                None
76            });
77            if let Some(value) = value {
78                signal.set(value);
79            }
80        }))
81    }
82
83    /// Creates a change event handler that updates a boolean signal from checkbox.
84    ///
85    /// # Arguments
86    ///
87    /// - `Signal<bool>` - The boolean signal to update with the checked state.
88    ///
89    /// # Returns
90    ///
91    /// - `Option<Rc<dyn Fn(Event)>>` - A change handler.
92    pub fn on_change_checked(signal: Signal<bool>) -> Option<Rc<dyn Fn(Event)>> {
93        Some(Rc::new(move |event: Event| {
94            if let Some(target) = event.target()
95                && let Ok(input) = target.clone().dyn_into::<HtmlInputElement>()
96            {
97                signal.set(input.checked());
98            }
99        }))
100    }
101
102    /// Focus gap (CSS) reserved between the focused input and the on-screen
103    /// keyboard. Small enough to feel tight, large enough that the caret does
104    /// not graze the IME top edge.
105    const FOCUS_GAP_PX: f64 = 12.0;
106
107    /// Time (ms) the browser / WebView is given to bring up the IME and
108    /// update the visual viewport before we measure element position.
109    const FOCUS_SCROLL_DELAY_MILLIS: i32 = 220;
110
111    /// Creates a focus handler that scrolls the focused input into the
112    /// visible area above the soft keyboard.
113    ///
114    /// The keyboard is accounted for entirely through the visual viewport:
115    /// hosts that overlay the IME shrink `visualViewport.height` (mobile
116    /// browsers with `interactive-widget=resizes-visual`), while immersive
117    /// hosts such as euv-app shrink the layout viewport itself through the
118    /// native inset bridge (WebView bottomMargin). Both paths place the
119    /// visible bottom edge at `visualViewport.height + offsetTop`, so this
120    /// handler never subtracts a keyboard height — doing so double-counts
121    /// the IME whenever the host has already resized the view.
122    ///
123    /// When the document is too short to scroll the input far enough, the
124    /// remaining deficit is added to `<main>` as an inline
125    /// `padding-bottom` (cleared on blur by [`Self::on_blur_restore_height`]).
126    pub fn on_focus_scroll_into_view() -> Option<Rc<dyn Fn(Event)>> {
127        Some(Rc::new(move |event: Event| {
128            let Some(target) = event.target() else {
129                return;
130            };
131            let Ok(element) = target.dyn_into::<HtmlElement>() else {
132                return;
133            };
134            let Some(window) = window() else {
135                return;
136            };
137            let element_clone: HtmlElement = element.clone();
138            let window_clone: Window = window.clone();
139            let closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
140                let visible_bottom: f64 = match window_clone.visual_viewport() {
141                    Some(viewport) => viewport.height() + viewport.offset_top(),
142                    None => window_clone
143                        .inner_height()
144                        .map(|height: JsValue| height.as_f64().unwrap_or_default())
145                        .unwrap_or_default(),
146                } - Self::FOCUS_GAP_PX;
147                if visible_bottom <= 0.0 {
148                    return;
149                }
150                let input_bottom: f64 = element_clone.get_bounding_client_rect().bottom();
151                if input_bottom <= visible_bottom {
152                    return;
153                }
154                let deficit: f64 = input_bottom - visible_bottom;
155                window_clone.scroll_by_with_x_and_y(0.0, deficit);
156                // Bottom-anchored input in a short document: the scroll above
157                // clamps at the document end, so pad <main> by exactly the
158                // remaining deficit and scroll once more. The padding equals
159                // the missing scroll room — never the full keyboard height.
160                let remaining: f64 =
161                    element_clone.get_bounding_client_rect().bottom() - visible_bottom;
162                if remaining > 0.0 {
163                    if let Ok(Some(main_el)) = element_clone.closest("main")
164                        && let Ok(main) = main_el.dyn_into::<HtmlElement>()
165                    {
166                        let _: Result<(), JsValue> = main
167                            .style()
168                            .set_property("padding-bottom", &format!("{remaining}px"));
169                    }
170                    window_clone.scroll_by_with_x_and_y(0.0, remaining);
171                }
172            }));
173            let _: Result<i32, JsValue> = window
174                .set_timeout_with_callback_and_timeout_and_arguments_0(
175                    closure.as_ref().unchecked_ref::<Function>(),
176                    Self::FOCUS_SCROLL_DELAY_MILLIS,
177                );
178            closure.forget();
179        }))
180    }
181
182    /// Blur handler that strips the inline `padding-bottom` injected by
183    /// [`Self::on_focus_scroll_into_view`] so the page returns to its
184    /// native layout once the keyboard closes.
185    pub fn on_blur_restore_height() -> Option<Rc<dyn Fn(Event)>> {
186        Some(Rc::new(move |event: Event| {
187            let Some(target) = event.target() else {
188                return;
189            };
190            let Ok(element) = target.dyn_into::<HtmlElement>() else {
191                return;
192            };
193            if let Ok(Some(main_el)) = element.closest("main")
194                && let Ok(main) = main_el.dyn_into::<HtmlElement>()
195            {
196                let _: Result<String, JsValue> = main.style().remove_property("padding-bottom");
197            }
198        }))
199    }
200}