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 ///
127 /// # Returns
128 ///
129 /// - `Option<Rc<dyn Fn(Event)>>` - An event handler performing the scroll on focus.
130 pub fn on_focus_scroll_into_view() -> Option<Rc<dyn Fn(Event)>> {
131 Some(Rc::new(move |event: Event| {
132 let Some(target) = event.target() else {
133 return;
134 };
135 let Ok(element) = target.dyn_into::<HtmlElement>() else {
136 return;
137 };
138 let Some(window) = window() else {
139 return;
140 };
141 let element_clone: HtmlElement = element.clone();
142 let window_clone: Window = window.clone();
143 let closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
144 let visible_bottom: f64 = match window_clone.visual_viewport() {
145 Some(viewport) => viewport.height() + viewport.offset_top(),
146 None => window_clone
147 .inner_height()
148 .map(|height: JsValue| height.as_f64().unwrap_or_default())
149 .unwrap_or_default(),
150 } - Self::FOCUS_GAP_PX;
151 if visible_bottom <= 0.0 {
152 return;
153 }
154 let input_bottom: f64 = element_clone.get_bounding_client_rect().bottom();
155 if input_bottom <= visible_bottom {
156 return;
157 }
158 let deficit: f64 = input_bottom - visible_bottom;
159 window_clone.scroll_by_with_x_and_y(0.0, deficit);
160 // Bottom-anchored input in a short document: the scroll above
161 // clamps at the document end, so pad <main> by exactly the
162 // remaining deficit and scroll once more. The padding equals
163 // the missing scroll room — never the full keyboard height.
164 let remaining: f64 =
165 element_clone.get_bounding_client_rect().bottom() - visible_bottom;
166 if remaining > 0.0 {
167 if let Ok(Some(main_el)) = element_clone.closest("main")
168 && let Ok(main) = main_el.dyn_into::<HtmlElement>()
169 {
170 let _: Result<(), JsValue> = main
171 .style()
172 .set_property("padding-bottom", &format!("{remaining}px"));
173 }
174 window_clone.scroll_by_with_x_and_y(0.0, remaining);
175 }
176 }));
177 let _: Result<i32, JsValue> = window
178 .set_timeout_with_callback_and_timeout_and_arguments_0(
179 closure.as_ref().unchecked_ref::<Function>(),
180 Self::FOCUS_SCROLL_DELAY_MILLIS,
181 );
182 closure.forget();
183 }))
184 }
185
186 /// Blur handler that strips the inline `padding-bottom` injected by
187 /// [`Self::on_focus_scroll_into_view`] so the page returns to its
188 /// native layout once the keyboard closes.
189 ///
190 /// # Returns
191 ///
192 /// - `Option<Rc<dyn Fn(Event)>>` - An event handler removing the inline padding on blur.
193 pub fn on_blur_restore_height() -> Option<Rc<dyn Fn(Event)>> {
194 Some(Rc::new(move |event: Event| {
195 let Some(target) = event.target() else {
196 return;
197 };
198 let Ok(element) = target.dyn_into::<HtmlElement>() else {
199 return;
200 };
201 if let Ok(Some(main_el)) = element.closest("main")
202 && let Ok(main) = main_el.dyn_into::<HtmlElement>()
203 {
204 let _: Result<String, JsValue> = main.style().remove_property("padding-bottom");
205 }
206 }))
207 }
208}