Skip to main content

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}