Skip to content

Commit e8ca47e

Browse files
committed
add use_start_typing hook by zed & deepseek
1 parent e472c9f commit e8ca47e

7 files changed

Lines changed: 341 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ fn counter() -> Html {
141141
- `use_hovered` - checks if an element is hovered.
142142
- `use_permission` - tracks browser's permission changes using the `Permissions` API.
143143
- `use_idle` - tracks whether the user is idle (not interacting with the page).
144+
- `use_start_typing` - triggers when user starts typing without an editable element focused.
144145

145146
### UI
146147

crates/yew-hooks/src/hooks/mod.rs

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

examples/yew-app/src/app/home.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ pub fn home() -> Html {
9696
<li><Link<AppRoute> to={AppRoute::UseHovered} classes="text-emerald-800 underline" >{ "use_hovered" }</Link<AppRoute>> { " - checks if an element is being hovered." }</li>
9797
<li><Link<AppRoute> to={AppRoute::UsePermission} classes="text-emerald-800 underline" >{ "use_permission" }</Link<AppRoute>> { " - tracks browser's permission changes using the Permissions API." }</li>
9898
<li><Link<AppRoute> to={AppRoute::UseIdle} classes="text-emerald-800 underline" >{ "use_idle" }</Link<AppRoute>> { " - tracks whether the user is idle (not interacting with the page)." }</li>
99+
<li><Link<AppRoute> to={AppRoute::UseStartTyping} classes="text-emerald-800 underline" >{ "use_start_typing" }</Link<AppRoute>> { " - triggers when user starts typing without an editable element focused." }</li>
99100
</ul>
100101

101102
<h2 class="text-2xl font-bold">{ "UI" }</h2>

examples/yew-app/src/app/hooks/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ mod use_search_param;
4747
mod use_session_storage;
4848
mod use_set;
4949
mod use_size;
50+
mod use_start_typing;
5051
mod use_state_ptr_eq;
5152
mod use_swipe;
5253
mod use_theme;
@@ -113,6 +114,7 @@ pub use use_search_param::*;
113114
pub use use_session_storage::*;
114115
pub use use_set::*;
115116
pub use use_size::*;
117+
pub use use_start_typing::*;
116118
pub use use_state_ptr_eq::*;
117119
pub use use_swipe::*;
118120
pub use use_theme::*;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
use yew::prelude::*;
2+
use yew_hooks::prelude::*;
3+
4+
/// `use_start_typing` demo
5+
#[function_component]
6+
pub fn UseStartTyping() -> Html {
7+
let input_ref = use_node_ref();
8+
9+
{
10+
let input_ref = input_ref.clone();
11+
use_start_typing(move |_event: KeyboardEvent| {
12+
// Focus the first input element
13+
if let Some(input) = input_ref.cast::<web_sys::HtmlInputElement>() {
14+
let _ = input.focus();
15+
}
16+
});
17+
}
18+
19+
html! {
20+
<div class="container">
21+
<header class="mt-24 text-xl text-center">
22+
<div class="space-y-8">
23+
<div class="max-w-2xl mx-auto p-6 rounded-lg shadow">
24+
<p class="mb-4">
25+
{ "This hook triggers when you type alphanumeric keys (A-Z, 0-9) " }
26+
{ "anywhere on the page, but ONLY when:" }
27+
</p>
28+
29+
<ul class="list-disc pl-6 mb-6 text-left">
30+
<li class="mb-2">{ "No editable element (input, textarea, or contenteditable) is focused" }</li>
31+
<li class="mb-2">{ "The pressed key is alphanumeric (A-Z, 0-9)" }</li>
32+
<li class="mb-2">{ "No modifier keys (Ctrl, Alt, Meta) are held" }</li>
33+
</ul>
34+
35+
<div class="space-y-4 mb-6">
36+
<div>
37+
<label class="block text-sm font-medium mb-2">{ "Try typing here (will NOT trigger):" }</label>
38+
<input
39+
ref={input_ref.clone()}
40+
type="text"
41+
class="w-full p-2 border border-gray-300 rounded"
42+
placeholder="Focus here and type - hook won't trigger"
43+
/>
44+
</div>
45+
46+
<div>
47+
<label class="block text-sm font-medium mb-2">{ "Or here (will NOT trigger):" }</label>
48+
<textarea
49+
class="w-full p-2 border border-gray-300 rounded"
50+
placeholder="Focus here and type - hook won't trigger"
51+
rows="2"
52+
value=""
53+
/>
54+
</div>
55+
56+
<div>
57+
<label class="block text-sm font-medium mb-2">{ "Or this contenteditable div (will NOT trigger):" }</label>
58+
<div
59+
contenteditable="true"
60+
class="w-full p-2 border border-gray-300 rounded min-h-[80px] bg-white"
61+
>
62+
{ "Click here and type - hook won't trigger" }
63+
</div>
64+
</div>
65+
</div>
66+
</div>
67+
</div>
68+
</header>
69+
</div>
70+
}
71+
}

examples/yew-app/src/app/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ pub enum AppRoute {
145145
UseCookie,
146146
#[at("/use_idle")]
147147
UseIdle,
148+
#[at("/use_start_typing")]
149+
UseStartTyping,
148150
#[not_found]
149151
#[at("/page-not-found")]
150152
PageNotFound,
@@ -222,6 +224,7 @@ pub fn switch(routes: AppRoute) -> Html {
222224
AppRoute::UsePermission => html! { <UsePermission /> },
223225
AppRoute::UseCookie => html! { <UseCookie /> },
224226
AppRoute::UseIdle => html! { <UseIdle /> },
227+
AppRoute::UseStartTyping => html! { <UseStartTyping /> },
225228
AppRoute::PageNotFound => html! { <Home /> },
226229
}
227230
}

0 commit comments

Comments
 (0)