leptos_dom/helpers.rs
1//! A variety of DOM utility functions.
2
3use or_poisoned::OrPoisoned;
4#[cfg(debug_assertions)]
5use reactive_graph::diagnostics::SpecialNonReactiveZone;
6use reactive_graph::owner::Owner;
7use send_wrapper::SendWrapper;
8use std::time::Duration;
9use tachys::html::event::EventDescriptor;
10#[cfg(feature = "tracing")]
11use tracing::instrument;
12use wasm_bindgen::{prelude::Closure, JsCast, JsValue, UnwrapThrowExt};
13
14thread_local! {
15 pub(crate) static WINDOW: web_sys::Window = web_sys::window().unwrap_throw();
16
17 pub(crate) static DOCUMENT: web_sys::Document = web_sys::window().unwrap_throw().document().unwrap_throw();
18}
19
20/// Returns the [`Window`](https://developer.mozilla.org/en-US/docs/Web/API/Window).
21///
22/// This is cached as a thread-local variable, so calling `window()` multiple times
23/// requires only one call out to JavaScript.
24pub fn window() -> web_sys::Window {
25 WINDOW.with(Clone::clone)
26}
27
28/// Returns the [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document).
29///
30/// This is cached as a thread-local variable, so calling `document()` multiple times
31/// requires only one call out to JavaScript.
32pub fn document() -> web_sys::Document {
33 DOCUMENT.with(Clone::clone)
34}
35
36/// Sets a property on a DOM element.
37pub fn set_property(
38 el: &web_sys::Element,
39 prop_name: &str,
40 value: &Option<JsValue>,
41) {
42 let key = JsValue::from_str(prop_name);
43 match value {
44 Some(value) => _ = js_sys::Reflect::set(el, &key, value),
45 None => _ = js_sys::Reflect::delete_property(el, &key),
46 };
47}
48
49/// Gets the value of a property set on a DOM element.
50#[doc(hidden)]
51pub fn get_property(
52 el: &web_sys::Element,
53 prop_name: &str,
54) -> Result<JsValue, JsValue> {
55 let key = JsValue::from_str(prop_name);
56 js_sys::Reflect::get(el, &key)
57}
58
59/// Returns the current [`window.location`](https://developer.mozilla.org/en-US/docs/Web/API/Window/location).
60pub fn location() -> web_sys::Location {
61 window().location()
62}
63
64/// Current [`window.location.hash`](https://developer.mozilla.org/en-US/docs/Web/API/Window/location)
65/// without the beginning #.
66pub fn location_hash() -> Option<String> {
67 if is_server() {
68 None
69 } else {
70 location()
71 .hash()
72 .ok()
73 .map(|hash| match hash.chars().next() {
74 Some('#') => hash[1..].to_string(),
75 _ => hash,
76 })
77 }
78}
79
80/// Current [`window.location.pathname`](https://developer.mozilla.org/en-US/docs/Web/API/Window/location).
81pub fn location_pathname() -> Option<String> {
82 location().pathname().ok()
83}
84
85/// Helper function to extract [`Event.target`](https://developer.mozilla.org/en-US/docs/Web/API/Event/target)
86/// from any event.
87pub fn event_target<T>(event: &web_sys::Event) -> T
88where
89 T: JsCast,
90{
91 event.target().unwrap_throw().unchecked_into::<T>()
92}
93
94/// Helper function to extract `event.target.value` from an event.
95///
96/// This is useful in the `on:input` or `on:change` listeners for an `<input>` element.
97pub fn event_target_value<T>(event: &T) -> String
98where
99 T: JsCast,
100{
101 event
102 .unchecked_ref::<web_sys::Event>()
103 .target()
104 .unwrap_throw()
105 .unchecked_into::<web_sys::HtmlInputElement>()
106 .value()
107}
108
109/// Helper function to extract `event.target.checked` from an event.
110///
111/// This is useful in the `on:change` listeners for an `<input type="checkbox">` element.
112pub fn event_target_checked(ev: &web_sys::Event) -> bool {
113 ev.target()
114 .unwrap()
115 .unchecked_into::<web_sys::HtmlInputElement>()
116 .checked()
117}
118
119/// Handle that is generated by [request_animation_frame_with_handle] and can
120/// be used to cancel the animation frame request.
121#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
122pub struct AnimationFrameRequestHandle(i32);
123
124impl AnimationFrameRequestHandle {
125 /// Cancels the animation frame request to which this refers.
126 /// See [`cancelAnimationFrame()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/cancelAnimationFrame)
127 pub fn cancel(&self) {
128 _ = window().cancel_animation_frame(self.0);
129 }
130}
131
132/// Runs the given function between the next repaint using
133/// [`Window.requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame).
134///
135/// ### Note about Context
136///
137/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
138#[cfg_attr(feature = "tracing", instrument(level = "trace", skip_all))]
139#[inline(always)]
140pub fn request_animation_frame(cb: impl FnOnce() + 'static) {
141 _ = request_animation_frame_with_handle(cb);
142}
143
144// Closure::once_into_js only frees the callback when it's actually
145// called, so this instead uses into_js_value, which can be freed by
146// the host JS engine's GC if it supports weak references (which all
147// modern browser engines do). The way this works is that the provided
148// callback's captured data is dropped immediately after being called,
149// as before, but it leaves behind a small stub closure rust-side that
150// will be freed "eventually" by the JS GC. If the function is never
151// called (e.g., it's a cancelled timeout or animation frame callback)
152// then it will also be freed eventually.
153fn closure_once(cb: impl FnOnce() + 'static) -> JsValue {
154 let mut wrapped_cb: Option<Box<dyn FnOnce()>> = Some(Box::new(cb));
155 let closure = Closure::new(move || {
156 if let Some(cb) = wrapped_cb.take() {
157 cb()
158 }
159 });
160 closure.into_js_value()
161}
162
163/// Runs the given function between the next repaint using
164/// [`Window.requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame),
165/// returning a cancelable handle.
166///
167/// ### Note about Context
168///
169/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
170#[cfg_attr(feature = "tracing", instrument(level = "trace", skip_all))]
171#[inline(always)]
172pub fn request_animation_frame_with_handle(
173 cb: impl FnOnce() + 'static,
174) -> Result<AnimationFrameRequestHandle, JsValue> {
175 #[cfg(feature = "tracing")]
176 let span = ::tracing::Span::current();
177 #[cfg(feature = "tracing")]
178 let cb = move || {
179 let _guard = span.enter();
180 cb();
181 };
182
183 #[inline(never)]
184 fn raf(cb: JsValue) -> Result<AnimationFrameRequestHandle, JsValue> {
185 window()
186 .request_animation_frame(cb.as_ref().unchecked_ref())
187 .map(AnimationFrameRequestHandle)
188 }
189
190 raf(closure_once(cb))
191}
192
193/// Handle that is generated by [request_idle_callback_with_handle] and can be
194/// used to cancel the idle callback.
195#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
196pub struct IdleCallbackHandle(u32);
197
198impl IdleCallbackHandle {
199 /// Cancels the idle callback to which this refers.
200 /// See [`cancelAnimationFrame()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/cancelIdleCallback)
201 pub fn cancel(&self) {
202 window().cancel_idle_callback(self.0);
203 }
204}
205
206/// Queues the given function during an idle period using
207/// [`Window.requestIdleCallback`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestIdleCallback).
208///
209/// ### Note about Context
210///
211/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
212#[cfg_attr(feature = "tracing", instrument(level = "trace", skip_all))]
213#[inline(always)]
214pub fn request_idle_callback(cb: impl Fn() + 'static) {
215 _ = request_idle_callback_with_handle(cb);
216}
217
218/// Queues the given function during an idle period using
219/// [`Window.requestIdleCallback`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestIdleCallback),
220/// returning a cancelable handle.
221///
222/// ### Note about Context
223///
224/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
225#[cfg_attr(feature = "tracing", instrument(level = "trace", skip_all))]
226#[inline(always)]
227pub fn request_idle_callback_with_handle(
228 cb: impl Fn() + 'static,
229) -> Result<IdleCallbackHandle, JsValue> {
230 #[cfg(feature = "tracing")]
231 let span = ::tracing::Span::current();
232 #[cfg(feature = "tracing")]
233 let cb = move || {
234 let _guard = span.enter();
235 cb();
236 };
237
238 #[inline(never)]
239 fn ric(cb: Box<dyn Fn()>) -> Result<IdleCallbackHandle, JsValue> {
240 let cb = Closure::wrap(cb).into_js_value();
241
242 window()
243 .request_idle_callback(cb.as_ref().unchecked_ref())
244 .map(IdleCallbackHandle)
245 }
246
247 ric(Box::new(cb))
248}
249
250/// A microtask is a short function which will run after the current task has
251/// completed its work and when there is no other code waiting to be run before
252/// control of the execution context is returned to the browser's event loop.
253///
254/// Microtasks are especially useful for libraries and frameworks that need
255/// to perform final cleanup or other just-before-rendering tasks.
256///
257/// [MDN queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask)
258///
259/// <div class="warning">The task is called outside of the ownership tree, this means that if you want to access for example the context you need to reestablish the owner.</div>
260pub fn queue_microtask(task: impl FnOnce() + 'static) {
261 tachys::renderer::dom::queue_microtask(task);
262}
263
264/// Handle that is generated by [set_timeout_with_handle] and can be used to clear the timeout.
265#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
266pub struct TimeoutHandle(i32);
267
268impl TimeoutHandle {
269 /// Cancels the timeout to which this refers.
270 /// See [`clearTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout)
271 pub fn clear(&self) {
272 window().clear_timeout_with_handle(self.0);
273 }
274}
275
276/// Executes the given function after the given duration of time has passed.
277/// [`setTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout).
278///
279/// ### Note about Context
280///
281/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
282#[cfg_attr(
283 feature = "tracing",
284 instrument(level = "trace", skip_all, fields(duration = ?duration))
285)]
286pub fn set_timeout(cb: impl FnOnce() + 'static, duration: Duration) {
287 _ = set_timeout_with_handle(cb, duration);
288}
289
290/// Executes the given function after the given duration of time has passed, returning a cancelable handle.
291/// [`setTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout).
292///
293/// ### Note about Context
294///
295/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
296#[cfg_attr(
297 feature = "tracing",
298 instrument(level = "trace", skip_all, fields(duration = ?duration))
299)]
300#[inline(always)]
301pub fn set_timeout_with_handle(
302 cb: impl FnOnce() + 'static,
303 duration: Duration,
304) -> Result<TimeoutHandle, JsValue> {
305 #[cfg(debug_assertions)]
306 let cb = || {
307 let _z = SpecialNonReactiveZone::enter();
308 cb();
309 };
310
311 #[cfg(feature = "tracing")]
312 let span = ::tracing::Span::current();
313 #[cfg(feature = "tracing")]
314 let cb = move || {
315 let _guard = span.enter();
316 cb();
317 };
318
319 #[inline(never)]
320 fn st(cb: JsValue, duration: Duration) -> Result<TimeoutHandle, JsValue> {
321 window()
322 .set_timeout_with_callback_and_timeout_and_arguments_0(
323 cb.as_ref().unchecked_ref(),
324 duration.as_millis().try_into().unwrap_throw(),
325 )
326 .map(TimeoutHandle)
327 }
328
329 st(closure_once(cb), duration)
330}
331
332/// "Debounce" a callback function. This will cause it to wait for a period of `delay`
333/// after it is called. If it is called again during that period, it will wait
334/// `delay` before running, and so on. This can be used, for example, to wrap event
335/// listeners to prevent them from firing constantly as you type.
336///
337/// ```
338/// use leptos::{leptos_dom::helpers::debounce, logging::log, prelude::*, *};
339///
340/// #[component]
341/// fn DebouncedButton() -> impl IntoView {
342/// let delay = std::time::Duration::from_millis(250);
343/// let on_click = debounce(delay, move |_| {
344/// log!("...so many clicks!");
345/// });
346///
347/// view! {
348/// <button on:click=on_click>"Click me"</button>
349/// }
350/// }
351/// ```
352///
353/// ### Note about Context
354///
355/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
356pub fn debounce<T: 'static>(
357 delay: Duration,
358 mut cb: impl FnMut(T) + 'static,
359) -> impl FnMut(T) {
360 use std::sync::{Arc, RwLock};
361
362 #[cfg(debug_assertions)]
363 #[allow(unused_mut)]
364 let mut cb = move |value| {
365 let _z = SpecialNonReactiveZone::enter();
366 cb(value);
367 };
368
369 #[cfg(feature = "tracing")]
370 let span = ::tracing::Span::current();
371 #[cfg(feature = "tracing")]
372 #[allow(unused_mut)]
373 let mut cb = move |value| {
374 let _guard = span.enter();
375 cb(value);
376 };
377
378 let cb = Arc::new(RwLock::new(cb));
379 let timer = Arc::new(RwLock::new(None::<TimeoutHandle>));
380
381 Owner::on_cleanup({
382 let timer = Arc::clone(&timer);
383 move || {
384 if let Some(timer) = timer.write().or_poisoned().take() {
385 timer.clear();
386 }
387 }
388 });
389
390 move |arg| {
391 if let Some(timer) = timer.write().unwrap().take() {
392 timer.clear();
393 }
394 let handle = set_timeout_with_handle(
395 {
396 let cb = Arc::clone(&cb);
397 move || {
398 cb.write().unwrap()(arg);
399 }
400 },
401 delay,
402 );
403 if let Ok(handle) = handle {
404 *timer.write().or_poisoned() = Some(handle);
405 }
406 }
407}
408
409/// Handle that is generated by [set_interval] and can be used to clear the interval.
410#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
411pub struct IntervalHandle(i32);
412
413impl IntervalHandle {
414 /// Cancels the repeating event to which this refers.
415 /// See [`clearInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval)
416 pub fn clear(&self) {
417 window().clear_interval_with_handle(self.0);
418 }
419}
420
421/// Repeatedly calls the given function, with a delay of the given duration between calls.
422/// See [`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval).
423///
424/// ### Note about Context
425///
426/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
427#[cfg_attr(
428 feature = "tracing",
429 instrument(level = "trace", skip_all, fields(duration = ?duration))
430)]
431pub fn set_interval(cb: impl Fn() + 'static, duration: Duration) {
432 _ = set_interval_with_handle(cb, duration);
433}
434
435/// Repeatedly calls the given function, with a delay of the given duration between calls,
436/// returning a cancelable handle.
437/// See [`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval).
438///
439/// ### Note about Context
440///
441/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
442#[cfg_attr(
443 feature = "tracing",
444 instrument(level = "trace", skip_all, fields(duration = ?duration))
445)]
446#[inline(always)]
447pub fn set_interval_with_handle(
448 cb: impl Fn() + 'static,
449 duration: Duration,
450) -> Result<IntervalHandle, JsValue> {
451 #[cfg(debug_assertions)]
452 let cb = move || {
453 let _z = SpecialNonReactiveZone::enter();
454 cb();
455 };
456 #[cfg(feature = "tracing")]
457 let span = ::tracing::Span::current();
458 #[cfg(feature = "tracing")]
459 let cb = move || {
460 let _guard = span.enter();
461 cb();
462 };
463
464 #[inline(never)]
465 fn si(
466 cb: Box<dyn FnMut()>,
467 duration: Duration,
468 ) -> Result<IntervalHandle, JsValue> {
469 let cb = Closure::wrap(cb).into_js_value();
470
471 window()
472 .set_interval_with_callback_and_timeout_and_arguments_0(
473 cb.as_ref().unchecked_ref(),
474 duration.as_millis().try_into().unwrap_throw(),
475 )
476 .map(IntervalHandle)
477 }
478
479 si(Box::new(cb), duration)
480}
481
482/// Adds an event listener to the `Window`, typed as a generic `Event`,
483/// returning a cancelable handle.
484///
485/// ### Note about Context
486///
487/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
488#[cfg_attr(
489 feature = "tracing",
490 instrument(level = "trace", skip_all, fields(event_name = %event_name))
491)]
492#[inline(always)]
493pub fn window_event_listener_untyped(
494 event_name: &str,
495 cb: impl Fn(web_sys::Event) + 'static,
496) -> WindowListenerHandle {
497 #[cfg(debug_assertions)]
498 let cb = move |e| {
499 let _z = SpecialNonReactiveZone::enter();
500 cb(e);
501 };
502 #[cfg(feature = "tracing")]
503 let span = ::tracing::Span::current();
504 #[cfg(feature = "tracing")]
505 let cb = move |e| {
506 let _guard = span.enter();
507 cb(e);
508 };
509
510 if !is_server() {
511 #[inline(never)]
512 fn wel(
513 cb: Box<dyn FnMut(web_sys::Event)>,
514 event_name: &str,
515 ) -> WindowListenerHandle {
516 let cb = Closure::wrap(cb).into_js_value();
517 _ = window().add_event_listener_with_callback(
518 event_name,
519 cb.unchecked_ref(),
520 );
521 let event_name = event_name.to_string();
522 let cb = SendWrapper::new(cb);
523 WindowListenerHandle(Box::new(move || {
524 _ = window().remove_event_listener_with_callback(
525 &event_name,
526 cb.unchecked_ref(),
527 );
528 }))
529 }
530
531 wel(Box::new(cb), event_name)
532 } else {
533 WindowListenerHandle(Box::new(|| ()))
534 }
535}
536
537/// Creates a window event listener from a typed event, returning a
538/// cancelable handle.
539/// ```
540/// use leptos::{
541/// ev, leptos_dom::helpers::window_event_listener, logging::log,
542/// prelude::*,
543/// };
544///
545/// #[component]
546/// fn App() -> impl IntoView {
547/// let handle = window_event_listener(ev::keypress, |ev| {
548/// // ev is typed as KeyboardEvent automatically,
549/// // so .code() can be called
550/// let code = ev.code();
551/// log!("code = {code:?}");
552/// });
553/// on_cleanup(move || handle.remove());
554/// }
555/// ```
556///
557/// ### Note about Context
558///
559/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
560pub fn window_event_listener<E: EventDescriptor + 'static>(
561 event: E,
562 cb: impl Fn(E::EventType) + 'static,
563) -> WindowListenerHandle
564where
565 E::EventType: JsCast,
566{
567 window_event_listener_untyped(&event.name(), move |e| {
568 cb(e.unchecked_into::<E::EventType>())
569 })
570}
571
572/// A handle that can be called to remove a global event listener.
573pub struct WindowListenerHandle(Box<dyn FnOnce() + Send + Sync>);
574
575impl core::fmt::Debug for WindowListenerHandle {
576 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
577 f.debug_tuple("WindowListenerHandle").finish()
578 }
579}
580
581impl WindowListenerHandle {
582 /// Removes the event listener.
583 pub fn remove(self) {
584 (self.0)()
585 }
586}
587
588/// Returns `true` if the current environment is a server.
589pub fn is_server() -> bool {
590 #[cfg(feature = "hydration")]
591 {
592 Owner::current_shared_context()
593 .map(|sc| !sc.is_browser())
594 .unwrap_or(false)
595 }
596 #[cfg(not(feature = "hydration"))]
597 {
598 false
599 }
600}
601
602/// Returns `true` if the current environment is a browser.
603pub fn is_browser() -> bool {
604 !is_server()
605}