leptos_use/
use_scroll.rs

1use crate::UseEventListenerOptions;
2use crate::core::{Direction, Directions, IntoElementMaybeSignal};
3use cfg_if::cfg_if;
4use default_struct_builder::DefaultBuilder;
5use leptos::prelude::*;
6use leptos::reactive::wrappers::read::Signal;
7use std::rc::Rc;
8
9cfg_if! { if #[cfg(not(feature = "ssr"))] {
10use crate::use_event_listener::use_event_listener_with_options;
11use crate::{
12    sendwrap_fn, use_debounce_fn_with_arg, use_throttle_fn_with_arg_and_options, ThrottleOptions,
13};
14use leptos::ev;
15use leptos::ev::scrollend;
16use wasm_bindgen::JsCast;
17
18/// We have to check if the scroll amount is close enough to some threshold in order to
19/// more accurately calculate arrivedState. This is because scrollTop/scrollLeft are non-rounded
20/// numbers, while scrollHeight/scrollWidth and clientHeight/clientWidth are rounded.
21/// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
22const ARRIVED_STATE_THRESHOLD_PIXELS: f64 = 1.0;
23}}
24
25/// Reactive scroll position and state.
26///
27/// ## Demo
28///
29/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_scroll)
30///
31/// ## Usage
32///
33/// ```
34/// # use leptos::prelude::*;
35/// # use leptos::ev::resize;
36/// # use leptos::html::Div;
37/// # use leptos_use::{use_scroll, UseScrollReturn};
38/// #
39/// # #[component]
40/// # fn Demo() -> impl IntoView {
41/// let element = NodeRef::<Div>::new();
42///
43/// let UseScrollReturn {
44///     x, y, set_x, set_y, is_scrolling, arrived_state, directions, ..
45/// } = use_scroll(element);
46///
47/// view! {
48///     <div node_ref=element>"..."</div>
49/// }
50/// # }
51/// ```
52///
53/// ### With Offsets
54///
55/// You can provide offsets when you use [`use_scroll_with_options`].
56/// These offsets are thresholds in pixels when a side is considered to have arrived. This is reflected in the return field `arrived_state`.
57///
58/// ```
59/// # use leptos::prelude::*;
60/// # use leptos::html::Div;
61/// # use leptos::ev::resize;
62/// # use leptos_use::{use_scroll_with_options, UseScrollReturn, UseScrollOptions, ScrollOffset};
63/// #
64/// # #[component]
65/// # fn Demo() -> impl IntoView {
66/// # let element = NodeRef::<Div>::new();
67/// #
68/// let UseScrollReturn {
69///     x,
70///     y,
71///     set_x,
72///     set_y,
73///     is_scrolling,
74///     arrived_state,
75///     directions,
76///     ..
77/// } = use_scroll_with_options(
78///     element,
79///     UseScrollOptions::default().offset(ScrollOffset {
80///         top: 30.0,
81///         bottom: 30.0,
82///         right: 30.0,
83///         left: 30.0,
84///     }),
85/// );
86/// #
87/// #     view! { /// #         <div node_ref=element>"..."</div>
88/// #     }
89/// # }
90/// ```
91///
92/// ### Setting Scroll Position
93///
94/// Set the `x` and `y` values to make the element scroll to that position.
95///
96/// ```
97/// # use leptos::prelude::*;
98/// # use leptos::html::Div;
99/// # use leptos::ev::resize;
100/// # use leptos_use::{use_scroll, UseScrollReturn};
101/// #
102/// # #[component]
103/// # fn Demo() -> impl IntoView {
104/// let element = NodeRef::<Div>::new();
105///
106/// let UseScrollReturn {
107///     x, y, set_x, set_y, ..
108/// } = use_scroll(element);
109///
110/// view! {
111///     <div node_ref=element>"..."</div>
112///     <button on:click=move |_| set_x(x.get_untracked() + 10.0)>"Scroll right 10px"</button>
113///     <button on:click=move |_| set_y(y.get_untracked() + 10.0)>"Scroll down 10px"</button>
114/// }
115/// # }
116/// ```
117///
118/// ### Smooth Scrolling
119///
120/// Set `behavior: smooth` to enable smooth scrolling. The `behavior` option defaults to `auto`,
121/// which means no smooth scrolling. See the `behavior` option on
122/// [Element.scrollTo](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo) for more information.
123///
124/// ```
125/// # use leptos::prelude::*;
126/// # use leptos::ev::resize;
127/// # use leptos::html::Div;
128/// # use leptos_use::{use_scroll_with_options, UseScrollReturn, UseScrollOptions, ScrollBehavior};
129/// #
130/// # #[component]
131/// # fn Demo() -> impl IntoView {
132/// # let element = NodeRef::<Div>::new();
133/// #
134/// let UseScrollReturn {
135///     x, y, set_x, set_y, ..
136/// } = use_scroll_with_options(
137///     element,
138///     UseScrollOptions::default().behavior(ScrollBehavior::Smooth),
139/// );
140/// #
141/// # view! { /// #     <div node_ref=element>"..."</div>
142/// # }
143/// # }
144/// ```
145///
146/// or as a `Signal`:
147///
148/// ```
149/// # use leptos::prelude::*;
150/// # use leptos::ev::resize;
151/// # use leptos::html::Div;
152/// # use leptos_use::{use_scroll_with_options, UseScrollReturn, UseScrollOptions, ScrollBehavior};
153/// #
154/// # #[component]
155/// # fn Demo() -> impl IntoView {
156/// # let element = NodeRef::<Div>::new();
157/// #
158/// let (smooth, set_smooth) = signal(false);
159///
160/// let behavior = Signal::derive(move || {
161///     if smooth.get() { ScrollBehavior::Smooth } else { ScrollBehavior::Auto }
162/// });
163///
164/// let UseScrollReturn {
165///     x, y, set_x, set_y, ..
166/// } = use_scroll_with_options(
167///     element,
168///     UseScrollOptions::default().behavior(behavior),
169/// );
170/// #
171/// # view! { /// #     <div node_ref=element>"..."</div>
172/// # }
173/// # }
174/// ```
175///
176/// ## SendWrapped Return
177///
178/// The returned closures `set_x`, `set_y` and `measure` are sendwrapped functions. They can
179/// only be called from the same thread that called `use_scroll`.
180///
181/// ## Server-Side Rendering
182///
183/// > Make sure you follow the [instructions in Server-Side Rendering](https://leptos-use.rs/server_side_rendering.html).
184///
185/// On the server this returns signals that don't change and setters that are noops.
186pub fn use_scroll<El, M>(
187    element: El,
188) -> UseScrollReturn<
189    impl Fn(f64) + Clone + Send + Sync,
190    impl Fn(f64) + Clone + Send + Sync,
191    impl Fn() + Clone + Send + Sync,
192>
193where
194    El: IntoElementMaybeSignal<web_sys::Element, M>,
195{
196    use_scroll_with_options(element, Default::default())
197}
198
199/// Version of [`use_scroll`] with options. See [`use_scroll`] for how to use.
200#[cfg_attr(feature = "ssr", allow(unused_variables))]
201pub fn use_scroll_with_options<El, M>(
202    element: El,
203    options: UseScrollOptions,
204) -> UseScrollReturn<
205    impl Fn(f64) + Clone + Send + Sync,
206    impl Fn(f64) + Clone + Send + Sync,
207    impl Fn() + Clone + Send + Sync,
208>
209where
210    El: IntoElementMaybeSignal<web_sys::Element, M>,
211{
212    let (internal_x, set_internal_x) = signal(0.0);
213    let (internal_y, set_internal_y) = signal(0.0);
214
215    let (is_scrolling, set_is_scrolling) = signal(false);
216
217    let arrived_state = RwSignal::new(Directions {
218        left: true,
219        right: false,
220        top: true,
221        bottom: false,
222    });
223    let directions = RwSignal::new(Directions {
224        left: false,
225        right: false,
226        top: false,
227        bottom: false,
228    });
229
230    let set_x;
231    let set_y;
232    let measure;
233
234    #[cfg(feature = "ssr")]
235    {
236        set_x = |_| {};
237        set_y = |_| {};
238        measure = || {};
239    }
240
241    #[cfg(not(feature = "ssr"))]
242    {
243        let signal = element.into_element_maybe_signal();
244        let behavior = options.behavior;
245
246        let scroll_to = move |x: Option<f64>, y: Option<f64>| {
247            let element = signal.get_untracked();
248
249            if let Some(element) = element {
250                let scroll_options = web_sys::ScrollToOptions::new();
251                scroll_options.set_behavior(behavior.get_untracked().into());
252
253                if let Some(x) = x {
254                    scroll_options.set_left(x);
255                }
256                if let Some(y) = y {
257                    scroll_options.set_top(y);
258                }
259
260                element.scroll_to_with_scroll_to_options(&scroll_options);
261            }
262        };
263
264        set_x = sendwrap_fn!(move |x| scroll_to(Some(x), None));
265
266        set_y = sendwrap_fn!(move |y| scroll_to(None, Some(y)));
267
268        let on_scroll_end = {
269            let on_stop = Rc::clone(&options.on_stop);
270
271            move |e| {
272                if !is_scrolling.try_get_untracked().unwrap_or_default() {
273                    return;
274                }
275
276                set_is_scrolling.set(false);
277                directions.update(|directions| {
278                    directions.left = false;
279                    directions.right = false;
280                    directions.top = false;
281                    directions.bottom = false;
282                    on_stop.clone()(e);
283                });
284            }
285        };
286
287        let throttle = options.throttle;
288
289        let on_scroll_end_debounced =
290            use_debounce_fn_with_arg(on_scroll_end.clone(), throttle + options.idle);
291
292        let offset = options.offset;
293
294        let set_arrived_state = move |target: web_sys::Element| {
295            let style = window()
296                .get_computed_style(&target)
297                .expect("failed to get computed style");
298
299            if let Some(style) = style {
300                let display = style
301                    .get_property_value("display")
302                    .expect("failed to get display");
303                let flex_direction = style
304                    .get_property_value("flex-direction")
305                    .expect("failed to get flex-direction");
306
307                let scroll_left = target.scroll_left() as f64;
308                let scroll_left_abs = scroll_left.abs();
309
310                directions.update(|directions| {
311                    directions.left = scroll_left < internal_x.get_untracked();
312                    directions.right = scroll_left > internal_x.get_untracked();
313                });
314
315                let left = scroll_left_abs <= offset.left;
316                let right = scroll_left_abs + target.client_width() as f64
317                    >= target.scroll_width() as f64 - offset.right - ARRIVED_STATE_THRESHOLD_PIXELS;
318
319                arrived_state.update(|arrived_state| {
320                    if display == "flex" && flex_direction == "row-reverse" {
321                        arrived_state.left = right;
322                        arrived_state.right = left;
323                    } else {
324                        arrived_state.left = left;
325                        arrived_state.right = right;
326                    }
327                });
328                set_internal_x.set(scroll_left);
329
330                let mut scroll_top = target.scroll_top() as f64;
331
332                // patch for mobile compatibility
333                if target == document().unchecked_into::<web_sys::Element>() && scroll_top == 0.0 {
334                    scroll_top = document().body().expect("failed to get body").scroll_top() as f64;
335                }
336
337                let scroll_top_abs = scroll_top.abs();
338
339                directions.update(|directions| {
340                    directions.top = scroll_top < internal_y.get_untracked();
341                    directions.bottom = scroll_top > internal_y.get_untracked();
342                });
343
344                let top = scroll_top_abs <= offset.top;
345                let bottom = scroll_top_abs + target.client_height() as f64
346                    >= target.scroll_height() as f64
347                        - offset.bottom
348                        - ARRIVED_STATE_THRESHOLD_PIXELS;
349
350                // reverse columns and rows behave exactly the other way around,
351                // bottom is treated as top and top is treated as the negative version of bottom
352                arrived_state.update(|arrived_state| {
353                    if display == "flex" && flex_direction == "column-reverse" {
354                        arrived_state.top = bottom;
355                        arrived_state.bottom = top;
356                    } else {
357                        arrived_state.top = top;
358                        arrived_state.bottom = bottom;
359                    }
360                });
361
362                set_internal_y.set(scroll_top);
363            }
364        };
365
366        let on_scroll_handler = {
367            let on_scroll = Rc::clone(&options.on_scroll);
368
369            move |e: web_sys::Event| {
370                let target: web_sys::Element = event_target(&e);
371
372                set_arrived_state(target);
373                set_is_scrolling.set(true);
374
375                on_scroll_end_debounced.clone()(e.clone());
376                on_scroll.clone()(e);
377            }
378        };
379
380        let target = Signal::derive_local(move || {
381            let element = signal.get();
382            element.map(|element| element.unchecked_into::<web_sys::EventTarget>())
383        });
384
385        if throttle >= 0.0 {
386            let throttled_scroll_handler = use_throttle_fn_with_arg_and_options(
387                on_scroll_handler.clone(),
388                throttle,
389                ThrottleOptions {
390                    trailing: true,
391                    leading: false,
392                },
393            );
394
395            let handler = move |e: web_sys::Event| {
396                throttled_scroll_handler.clone()(e);
397            };
398
399            let _ = use_event_listener_with_options::<
400                _,
401                Signal<Option<web_sys::EventTarget>, LocalStorage>,
402                _,
403                _,
404            >(target, ev::scroll, handler, options.event_listener_options);
405        } else {
406            let _ = use_event_listener_with_options::<
407                _,
408                Signal<Option<web_sys::EventTarget>, LocalStorage>,
409                _,
410                _,
411            >(
412                target,
413                ev::scroll,
414                on_scroll_handler,
415                options.event_listener_options,
416            );
417        }
418
419        let _ = use_event_listener_with_options::<
420            _,
421            Signal<Option<web_sys::EventTarget>, LocalStorage>,
422            _,
423            _,
424        >(
425            target,
426            scrollend,
427            on_scroll_end,
428            options.event_listener_options,
429        );
430
431        measure = sendwrap_fn!(move || {
432            if let Some(el) = signal.try_get_untracked().flatten() {
433                set_arrived_state(el);
434            }
435        });
436    }
437
438    UseScrollReturn {
439        x: internal_x.into(),
440        set_x,
441        y: internal_y.into(),
442        set_y,
443        is_scrolling: is_scrolling.into(),
444        arrived_state: arrived_state.into(),
445        directions: directions.into(),
446        measure,
447    }
448}
449
450/// Options for [`use_scroll`].
451#[derive(DefaultBuilder)]
452/// Options for [`use_scroll_with_options`].
453#[cfg_attr(feature = "ssr", allow(dead_code))]
454pub struct UseScrollOptions {
455    /// Throttle time in milliseconds for the scroll events. Defaults to 0 (disabled).
456    throttle: f64,
457
458    /// After scrolling ends we wait idle + throttle milliseconds before we consider scrolling to have stopped.
459    /// Defaults to 200.
460    idle: f64,
461
462    /// Threshold in pixels when we consider a side to have arrived (`UseScrollReturn::arrived_state`).
463    offset: ScrollOffset,
464
465    /// Callback when scrolling is happening.
466    on_scroll: Rc<dyn Fn(web_sys::Event)>,
467
468    /// Callback when scrolling stops (after `idle` + `throttle` milliseconds have passed).
469    on_stop: Rc<dyn Fn(web_sys::Event)>,
470
471    /// Options passed to the `addEventListener("scroll", ...)` call
472    event_listener_options: UseEventListenerOptions,
473
474    /// When changing the `x` or `y` signals this specifies the scroll behaviour.
475    /// Can be `Auto` (= not smooth) or `Smooth`. Defaults to `Auto`.
476    #[builder(into)]
477    behavior: Signal<ScrollBehavior>,
478}
479
480impl Default for UseScrollOptions {
481    fn default() -> Self {
482        Self {
483            throttle: 0.0,
484            idle: 200.0,
485            offset: ScrollOffset::default(),
486            on_scroll: Rc::new(|_| {}),
487            on_stop: Rc::new(|_| {}),
488            event_listener_options: Default::default(),
489            behavior: Default::default(),
490        }
491    }
492}
493
494/// The scroll behavior.
495/// Can be `Auto` (= not smooth) or `Smooth`. Defaults to `Auto`.
496#[derive(Default, Copy, Clone)]
497pub enum ScrollBehavior {
498    #[default]
499    Auto,
500    Smooth,
501}
502
503impl From<ScrollBehavior> for web_sys::ScrollBehavior {
504    fn from(val: ScrollBehavior) -> Self {
505        match val {
506            ScrollBehavior::Auto => web_sys::ScrollBehavior::Auto,
507            ScrollBehavior::Smooth => web_sys::ScrollBehavior::Smooth,
508        }
509    }
510}
511
512/// The return value of [`use_scroll`].
513pub struct UseScrollReturn<SetXFn, SetYFn, MFn>
514where
515    SetXFn: Fn(f64) + Clone + Send + Sync,
516    SetYFn: Fn(f64) + Clone + Send + Sync,
517    MFn: Fn() + Clone + Send + Sync,
518{
519    /// X coordinate of scroll position
520    pub x: Signal<f64>,
521
522    /// Sets the value of `x`. This does also scroll the element.
523    pub set_x: SetXFn,
524
525    /// Y coordinate of scroll position
526    pub y: Signal<f64>,
527
528    /// Sets the value of `y`. This does also scroll the element.
529    pub set_y: SetYFn,
530
531    /// Is true while the element is being scrolled.
532    pub is_scrolling: Signal<bool>,
533
534    /// Sets the field that represents a direction to true if the
535    /// element is scrolled all the way to that side.
536    pub arrived_state: Signal<Directions>,
537
538    /// The directions in which the element is being scrolled are set to true.
539    pub directions: Signal<Directions>,
540
541    /// Re-evaluates the `arrived_state`.
542    pub measure: MFn,
543}
544
545#[derive(Default, Copy, Clone, Debug)]
546/// Threshold in pixels when we consider a side to have arrived (`UseScrollReturn::arrived_state`).
547pub struct ScrollOffset {
548    pub left: f64,
549    pub top: f64,
550    pub right: f64,
551    pub bottom: f64,
552}
553
554impl ScrollOffset {
555    /// Sets the value of the provided direction
556    pub fn set_direction(mut self, direction: Direction, value: f64) -> Self {
557        match direction {
558            Direction::Top => self.top = value,
559            Direction::Bottom => self.bottom = value,
560            Direction::Left => self.left = value,
561            Direction::Right => self.right = value,
562        }
563
564        self
565    }
566}