yew_hooks/hooks/use_start_typing.rs
1use std::borrow::Cow;
2
3use gloo::events::EventListener;
4use gloo::utils::window;
5use wasm_bindgen::{JsCast, JsValue};
6use web_sys::{HtmlElement, HtmlInputElement, HtmlTextAreaElement, KeyboardEvent};
7use yew::prelude::*;
8
9use super::use_latest;
10
11/// Type alias for key filter function to reduce type complexity
12type KeyFilter = Box<dyn Fn(&str) -> bool>;
13
14/// A hook that triggers a callback when the user starts typing on the page
15/// without an editable element focused.
16///
17/// The callback only fires when:
18/// - No editable element (`<input>`, `<textarea>`, or `contenteditable`) is focused
19/// - The pressed key is alphanumeric (A-Z, 0-9)
20/// - No modifier keys (Ctrl, Alt, Meta) are held
21///
22/// This allows users to start typing anywhere on the page without accidentally
23/// triggering the callback when using keyboard shortcuts or interacting with form fields.
24///
25/// # Example
26///
27/// ```rust
28/// # use yew::prelude::*;
29/// # use log::debug;
30/// #
31/// use yew_hooks::prelude::*;
32///
33/// #[function_component(UseStartTyping)]
34/// fn start_typing() -> Html {
35/// use_start_typing(move |event: KeyboardEvent| {
36/// debug!("Started typing with key: {}", event.key());
37/// });
38///
39/// html! {
40/// <div>
41/// <p>{ "Try typing anywhere on the page (without focusing on an input field)." }</p>
42/// <input type="text" placeholder="Focus here and typing won't trigger the callback" />
43/// <textarea placeholder="Same for textarea" />
44/// <div contenteditable="true" style="border: 1px solid #ccc; padding: 8px; margin-top: 8px;">
45/// { "This is a contenteditable div. Typing here won't trigger the callback either." }
46/// </div>
47/// </div>
48/// }
49/// }
50/// ```
51#[hook]
52pub fn use_start_typing<F>(callback: F)
53where
54 F: Fn(KeyboardEvent) + 'static,
55{
56 use_start_typing_with_options(callback, UseStartTypingOptions::default())
57}
58
59pub struct UseStartTypingOptions {
60 /// The keyboard event type to listen for. Default: "keydown"
61 pub event_type: Cow<'static, str>,
62 /// Whether to check for editable elements. Default: true
63 pub check_editable: bool,
64 /// Whether to check for modifier keys. Default: true
65 pub check_modifiers: bool,
66 /// Custom function to determine if a key should trigger the callback.
67 /// If not provided, defaults to checking if the key is alphanumeric.
68 pub key_filter: Option<KeyFilter>,
69}
70
71impl Default for UseStartTypingOptions {
72 fn default() -> Self {
73 Self {
74 event_type: "keydown".into(),
75 check_editable: true,
76 check_modifiers: true,
77 key_filter: None,
78 }
79 }
80}
81
82impl Clone for UseStartTypingOptions {
83 fn clone(&self) -> Self {
84 Self {
85 event_type: self.event_type.clone(),
86 check_editable: self.check_editable,
87 check_modifiers: self.check_modifiers,
88 key_filter: None, // Can't clone function pointers, so we set to None
89 }
90 }
91}
92
93impl PartialEq for UseStartTypingOptions {
94 fn eq(&self, other: &Self) -> bool {
95 self.event_type == other.event_type
96 && self.check_editable == other.check_editable
97 && self.check_modifiers == other.check_modifiers
98 // We can't compare function pointers, so we just compare if both have Some or None
99 && self.key_filter.is_some() == other.key_filter.is_some()
100 }
101}
102
103impl std::fmt::Debug for UseStartTypingOptions {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("UseStartTypingOptions")
106 .field("event_type", &self.event_type)
107 .field("check_editable", &self.check_editable)
108 .field("check_modifiers", &self.check_modifiers)
109 .field(
110 "key_filter",
111 &if self.key_filter.is_some() {
112 "Some(...)"
113 } else {
114 "None"
115 },
116 )
117 .finish()
118 }
119}
120
121/// A hook that triggers a callback when the user starts typing on the page
122/// without an editable element focused, with custom event type.
123///
124/// This is similar to [`use_start_typing`] but allows specifying a custom event type.
125/// The callback only fires when:
126/// - No editable element (`<input>`, `<textarea>`, or `contenteditable`) is focused
127/// - The pressed key matches the provided event type pattern
128/// - No modifier keys (Ctrl, Alt, Meta) are held
129///
130/// # Example
131///
132/// ```rust
133/// # use yew::prelude::*;
134/// # use log::debug;
135/// #
136/// use yew_hooks::prelude::*;
137///
138/// #[function_component(UseStartTypingWithOptions)]
139/// fn start_typing_with_options() -> Html {
140/// use_start_typing_with_options(
141/// move |event: KeyboardEvent| {
142/// debug!("Started typing with key: {}", event.key());
143/// },
144/// UseStartTypingOptions {
145/// event_type: "keypress".into(),
146/// ..Default::default()
147/// },
148/// );
149///
150/// html! {
151/// <div>
152/// <p>{ "Try typing anywhere on the page (without focusing on an input field)." }</p>
153/// </div>
154/// }
155/// }
156/// ```
157#[hook]
158pub fn use_start_typing_with_options<F>(callback: F, options: UseStartTypingOptions)
159where
160 F: Fn(KeyboardEvent) + 'static,
161{
162 let callback = use_latest(callback);
163 let options_ref = use_latest(options);
164
165 use_effect_with((), move |_| {
166 let window = window();
167
168 // Helper function to check if an element is editable
169 fn is_editable_element(element: &web_sys::Element) -> bool {
170 if let Ok(input) = element.clone().dyn_into::<HtmlInputElement>() {
171 // Check if input is not disabled and is visible (not hidden)
172 !input.disabled() && input.type_() != "hidden"
173 } else if let Ok(textarea) = element.clone().dyn_into::<HtmlTextAreaElement>() {
174 // Check if textarea is not disabled
175 !textarea.disabled()
176 } else if let Ok(html_element) = element.clone().dyn_into::<HtmlElement>() {
177 // Check if element has contenteditable attribute set to true
178 html_element
179 .get_attribute("contenteditable")
180 .map(|value| value == "true")
181 .unwrap_or(false)
182 } else {
183 false
184 }
185 }
186
187 // Check if the currently focused element is editable
188 let is_editable_element_focused = {
189 let window = window.clone();
190 let options_ref = options_ref.clone();
191 move || {
192 if !options_ref.current().check_editable {
193 return false;
194 }
195
196 let document = match window.document() {
197 Some(doc) => doc,
198 None => return false,
199 };
200 let active_element = document.active_element();
201
202 if let Some(element) = active_element {
203 is_editable_element(&element)
204 } else {
205 false
206 }
207 }
208 };
209
210 let event_type = options_ref.current().event_type.clone();
211 let options_ref_clone = options_ref.clone();
212 let listener = EventListener::new(&window, event_type, move |event| {
213 let keyboard_event: KeyboardEvent = JsValue::from(event).into();
214 let options = &*options_ref_clone.current();
215
216 // Check if the event should trigger
217 if should_trigger_keyboard_event(&keyboard_event, options, &is_editable_element_focused)
218 {
219 (*callback.current())(keyboard_event);
220 }
221 });
222
223 move || drop(listener)
224 });
225}
226
227/// Helper function to determine if a keyboard event should trigger the callback
228fn should_trigger_keyboard_event(
229 keyboard_event: &KeyboardEvent,
230 options: &UseStartTypingOptions,
231 is_editable_element_focused: &dyn Fn() -> bool,
232) -> bool {
233 // Check modifier keys if enabled
234 if options.check_modifiers
235 && (keyboard_event.ctrl_key()
236 || keyboard_event.alt_key()
237 || keyboard_event.meta_key()
238 || keyboard_event.shift_key())
239 {
240 return false;
241 }
242
243 // Check if editable element is focused if enabled
244 if options.check_editable && is_editable_element_focused() {
245 return false;
246 }
247
248 // Check if key passes the filter
249 let key = keyboard_event.key();
250 if let Some(filter) = &options.key_filter {
251 filter(&key)
252 } else {
253 // Default key filter checks for alphanumeric keys
254 if key.len() == 1 {
255 let c = match key.chars().next() {
256 Some(c) => c,
257 None => return false,
258 };
259 c.is_ascii_alphanumeric()
260 } else {
261 false
262 }
263 }
264}