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 between the safe top and the soft keyboard.
113    ///
114    /// Reads `--euv-keyboard-height` (set by the native host /
115    /// `IMMERSIVE_SAFE_AREA_SCRIPT` page bridge) and the visual viewport.
116    /// If the input's bottom edge falls under
117    /// `viewport_bottom - keyboard_height - FOCUS_GAP_PX`, the page is
118    /// scrolled by the difference. A small inline `padding-bottom` is also
119    /// added to `<main>` (when one exists) so the document gains enough
120    /// scrollable space for the adjustment — the inline style is cleared
121    /// by [`Self::on_blur_restore_height`].
122    pub fn on_focus_scroll_into_view() -> Option<Rc<dyn Fn(Event)>> {
123        Some(Rc::new(move |event: Event| {
124            let Some(target) = event.target() else {
125                return;
126            };
127            let Ok(element) = target.dyn_into::<HtmlElement>() else {
128                return;
129            };
130            let Some(window) = window() else {
131                return;
132            };
133            let element_clone: HtmlElement = element.clone();
134            let window_clone: Window = window.clone();
135            if let Ok(Some(main_el)) = element.closest("main")
136                && let Ok(main) = main_el.dyn_into::<HtmlElement>()
137            {
138                let _: Result<(), JsValue> = main
139                    .style()
140                    .set_property("padding-bottom", "var(--euv-keyboard-height, 0px)");
141            }
142            let closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
143                let rect: DomRect = element_clone.get_bounding_client_rect();
144                let input_bottom: f64 = rect.bottom();
145                let viewport_height: f64 = window_clone
146                    .visual_viewport()
147                    .map(|viewport: VisualViewport| viewport.height())
148                    .unwrap_or_else(|| {
149                        window_clone
150                            .inner_height()
151                            .map(|height: JsValue| height.as_f64().unwrap_or_default())
152                            .unwrap_or_default()
153                    });
154                let document_value: Document = match window_clone.document() {
155                    Some(doc) => doc,
156                    None => return,
157                };
158                let probe: Element = match document_value.create_element("div") {
159                    Ok(el) => el,
160                    Err(_) => return,
161                };
162                let keyboard_height: f64 = window_clone
163                    .get_computed_style(&probe)
164                    .ok()
165                    .flatten()
166                    .and_then(|style| style.get_property_value("--euv-keyboard-height").ok())
167                    .and_then(|raw| {
168                        let trimmed = raw.trim().trim_end_matches("px").to_string();
169                        trimmed.parse::<f64>().ok()
170                    })
171                    .unwrap_or(0.0);
172                let visible_bottom: f64 = viewport_height - keyboard_height - Self::FOCUS_GAP_PX;
173                if input_bottom > visible_bottom && visible_bottom > 0.0 {
174                    let scroll_amount: f64 = input_bottom - visible_bottom;
175                    window_clone.scroll_by_with_x_and_y(0.0, scroll_amount);
176                }
177            }));
178            let _: Result<i32, JsValue> = window
179                .set_timeout_with_callback_and_timeout_and_arguments_0(
180                    closure.as_ref().unchecked_ref::<Function>(),
181                    Self::FOCUS_SCROLL_DELAY_MILLIS,
182                );
183            closure.forget();
184        }))
185    }
186
187    /// Blur handler that strips the inline `padding-bottom` injected by
188    /// [`Self::on_focus_scroll_into_view`] so the page returns to its
189    /// native layout once the keyboard closes.
190    pub fn on_blur_restore_height() -> Option<Rc<dyn Fn(Event)>> {
191        Some(Rc::new(move |event: Event| {
192            let Some(target) = event.target() else {
193                return;
194            };
195            let Ok(element) = target.dyn_into::<HtmlElement>() else {
196                return;
197            };
198            if let Ok(Some(main_el)) = element.closest("main")
199                && let Ok(main) = main_el.dyn_into::<HtmlElement>()
200            {
201                let _: Result<String, JsValue> = main.style().remove_property("padding-bottom");
202            }
203        }))
204    }
205}