Skip to main content

dioxus_bootstrap_css/
scrollspy.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2
3use dioxus::prelude::*;
4
5static NEXT_SCROLLSPY_ID: AtomicUsize = AtomicUsize::new(1);
6
7const DEFAULT_ROOT_MARGIN: &str = "0px 0px -25%";
8const DEFAULT_THRESHOLD: [f64; 3] = [0.1, 0.5, 1.0];
9
10/// Bootstrap Scrollspy tracks scroll position and updates active section state.
11///
12/// The `target` prop follows Bootstrap semantics: it points to the nav, list
13/// group, or simple links container whose links reference section ids. Use
14/// `root` for the body or custom scroll container being observed.
15///
16/// # Bootstrap HTML -> Dioxus
17///
18/// | HTML | Dioxus |
19/// |---|---|
20/// | `<body data-bs-spy="scroll" data-bs-target="#nav">` | `Scrollspy { target: "#nav", active: signal }` |
21/// | `<div data-bs-spy="scroll" data-bs-target="#nav" tabindex="0">` | `Scrollspy { target: "#nav", root: "#scroll-area", active: signal }` |
22///
23/// ```rust,no_run
24/// # use dioxus::prelude::*;
25/// # use dioxus_bootstrap_css::prelude::*;
26/// # fn _doctest() -> Element {
27/// let active_section = use_signal(|| String::new());
28/// rsx! {
29///     Scrollspy {
30///         target: "#docs-nav",
31///         root: "#docs-scroll",
32///         active: active_section,
33///         offset: 80,
34///     }
35///     nav { id: "docs-nav",
36///         a { class: if *active_section.read() == "intro" { "nav-link active" } else { "nav-link" },
37///             href: "#intro", "Intro" }
38///         a { class: if *active_section.read() == "features" { "nav-link active" } else { "nav-link" },
39///             href: "#features", "Features" }
40///     }
41/// }
42/// # }
43/// ```
44#[derive(Clone, PartialEq, Props)]
45pub struct ScrollspyProps {
46    /// CSS selector for nav/list/simple links container.
47    #[props(default = "body".to_string())]
48    pub target: String,
49    /// CSS selector for scroll container. Use `"body"` for viewport scroll.
50    #[props(default = "body".to_string())]
51    pub root: String,
52    /// Signal receives id currently active section.
53    pub active: Signal<String>,
54    /// Compatibility offset in pixels for fixed or sticky headers.
55    #[props(default = 0)]
56    pub offset: i32,
57    /// IntersectionObserver root margin.
58    #[props(default = DEFAULT_ROOT_MARGIN.to_string())]
59    pub root_margin: String,
60    /// IntersectionObserver thresholds.
61    #[props(default = DEFAULT_THRESHOLD.to_vec())]
62    pub threshold: Vec<f64>,
63    /// Change this value to force section/link discovery refresh.
64    #[props(default = 0)]
65    pub refresh_key: u64,
66    /// Smooth-scroll target links owned by `target`.
67    #[props(default = false)]
68    pub smooth_scroll: bool,
69}
70
71#[component]
72pub fn Scrollspy(props: ScrollspyProps) -> Element {
73    let instance_id = use_signal(next_scrollspy_id);
74    let mut listener_started = use_signal(|| false);
75    let cleanup_id = instance_id.read().clone();
76
77    let setup = ScrollspySetup {
78        instance_id: instance_id.read().clone(),
79        target: props.target.clone(),
80        root: props.root.clone(),
81        offset: props.offset,
82        root_margin: props.root_margin.clone(),
83        threshold: props.threshold.clone(),
84        refresh_key: props.refresh_key,
85        smooth_scroll: props.smooth_scroll,
86    };
87
88    use_drop(move || cleanup_scrollspy(cleanup_id));
89
90    use_effect(use_reactive(
91        (
92            &props.target,
93            &props.root,
94            &props.offset,
95            &props.root_margin,
96            &props.threshold,
97            &props.refresh_key,
98            &props.smooth_scroll,
99        ),
100        move |_| setup_scrollspy(setup.clone()),
101    ));
102
103    use_effect(move || {
104        if !*listener_started.read() {
105            listener_started.set(true);
106            listen_scrollspy_events(instance_id.read().clone(), props.active);
107        }
108    });
109
110    rsx! {}
111}
112
113#[derive(Clone)]
114struct ScrollspySetup {
115    instance_id: String,
116    target: String,
117    root: String,
118    offset: i32,
119    root_margin: String,
120    threshold: Vec<f64>,
121    refresh_key: u64,
122    smooth_scroll: bool,
123}
124
125fn next_scrollspy_id() -> String {
126    let id = NEXT_SCROLLSPY_ID.fetch_add(1, Ordering::Relaxed);
127    format!("dbcss-scrollspy-{id}")
128}
129
130fn setup_scrollspy(setup: ScrollspySetup) {
131    spawn(async move {
132        let script = SCROLLSPY_SETUP_SCRIPT
133            .replace("__ID__", &js_string(&setup.instance_id))
134            .replace("__TARGET__", &js_string(&setup.target))
135            .replace("__ROOT__", &js_string(&setup.root))
136            .replace("__ROOT_MARGIN__", &js_string(&setup.root_margin))
137            .replace("__THRESHOLD__", &threshold_js(&setup.threshold))
138            .replace("__OFFSET__", &setup.offset.to_string())
139            .replace(
140                "__SMOOTH_SCROLL__",
141                if setup.smooth_scroll { "true" } else { "false" },
142            )
143            .replace("__REFRESH_KEY__", &setup.refresh_key.to_string());
144
145        let _ = document::eval(&script).await;
146    });
147}
148
149fn listen_scrollspy_events(instance_id: String, mut active: Signal<String>) {
150    spawn(async move {
151        let mut last = String::new();
152
153        loop {
154            let script = SCROLLSPY_EVENT_SCRIPT
155                .replace("__ID__", &js_string(&instance_id))
156                .replace("__LAST__", &js_string(&last));
157
158            let Ok(value) = document::eval(&script).await else {
159                break;
160            };
161
162            let Some(next) = value.as_str() else {
163                continue;
164            };
165
166            if next != last {
167                last = next.to_string();
168                active.set(last.clone());
169            }
170        }
171    });
172}
173
174fn cleanup_scrollspy(instance_id: String) {
175    let script = SCROLLSPY_CLEANUP_SCRIPT.replace("__ID__", &js_string(&instance_id));
176    let _ = document::eval(&script);
177}
178
179fn js_string(value: &str) -> String {
180    format!("{value:?}")
181}
182
183fn threshold_js(threshold: &[f64]) -> String {
184    let mut values = threshold
185        .iter()
186        .copied()
187        .filter(|value| value.is_finite())
188        .map(|value| value.clamp(0.0, 1.0))
189        .collect::<Vec<_>>();
190
191    if values.is_empty() {
192        values = DEFAULT_THRESHOLD.to_vec();
193    }
194
195    format!(
196        "[{}]",
197        values
198            .iter()
199            .map(|value| format!("{value:.3}"))
200            .collect::<Vec<_>>()
201            .join(",")
202    )
203}
204
205const SCROLLSPY_SETUP_SCRIPT: &str = r##"
206(function() {
207    const id = __ID__;
208    const targetSelector = __TARGET__;
209    const rootSelector = __ROOT__;
210    const rootMargin = __ROOT_MARGIN__;
211    const threshold = __THRESHOLD__;
212    const offset = __OFFSET__;
213    const smoothScroll = __SMOOTH_SCROLL__;
214    const refreshKey = __REFRESH_KEY__;
215    const eventName = "dbcss:scrollspy:" + id;
216
217    window.__dbcssScrollspy = window.__dbcssScrollspy || {};
218    const previous = window.__dbcssScrollspy[id];
219    if (previous && typeof previous.cleanup === "function") {
220        previous.cleanup();
221    }
222
223    const state = {
224        active: "",
225        refreshKey,
226        cleanup: function() {}
227    };
228    window.__dbcssScrollspy[id] = state;
229
230    function resolveRoot(selector) {
231        const normalized = String(selector || "").trim();
232        if (!normalized || normalized === "body" || normalized === "html" || normalized === "window" || normalized === "document") {
233            return null;
234        }
235        return document.querySelector(normalized);
236    }
237
238    const rootElement = resolveRoot(rootSelector);
239    const scrollTarget = rootElement || window;
240    const targetElement = targetSelector ? document.querySelector(targetSelector) : null;
241    const scope = rootElement || document;
242    let sections = [];
243    let links = [];
244    let observer = null;
245    let mutationObserver = null;
246    let animationFrame = 0;
247    const linkCleanups = [];
248
249    function rootBounds() {
250        if (rootElement) {
251            const rect = rootElement.getBoundingClientRect();
252            return { top: rect.top, bottom: rect.bottom, height: rect.height };
253        }
254
255        const height = window.innerHeight || document.documentElement.clientHeight || 0;
256        return { top: 0, bottom: height, height };
257    }
258
259    function idFromSelector(selector) {
260        if (!selector || selector[0] !== "#") {
261            return "";
262        }
263
264        try {
265            return decodeURIComponent(selector.slice(1));
266        } catch (_) {
267            return selector.slice(1);
268        }
269    }
270
271    function idFromLink(link) {
272        return idFromSelector(link.getAttribute("href") || link.getAttribute("data-bs-target") || "");
273    }
274
275    function sectionFromId(sectionId) {
276        if (!sectionId) {
277            return null;
278        }
279
280        return document.getElementById(sectionId);
281    }
282
283    function elementVisible(element) {
284        if (!element || !element.isConnected) {
285            return false;
286        }
287
288        const style = window.getComputedStyle(element);
289        if (style.display === "none" || style.visibility === "hidden") {
290            return false;
291        }
292
293        const rect = element.getBoundingClientRect();
294        return rect.width > 0 && rect.height > 0;
295    }
296
297    function elementInScope(element) {
298        return !rootElement || rootElement.contains(element);
299    }
300
301    function discoverLinks() {
302        if (!targetElement) {
303            return [];
304        }
305
306        return Array.from(targetElement.querySelectorAll('a[href^="#"], [data-bs-target^="#"]'));
307    }
308
309    function discoverSections(nextLinks) {
310        const ids = [];
311        nextLinks.forEach(function(link) {
312            const sectionId = idFromLink(link);
313            if (sectionId && ids.indexOf(sectionId) === -1) {
314                ids.push(sectionId);
315            }
316        });
317
318        if (ids.length > 0) {
319            return ids
320                .map(sectionFromId)
321                .filter(function(section) {
322                    return section && elementInScope(section);
323                });
324        }
325
326        return Array.from(scope.querySelectorAll("[id]")).filter(function(section) {
327            return !targetElement || !targetElement.contains(section);
328        });
329    }
330
331    function updateLinks(activeId) {
332        links.forEach(function(link) {
333            const isActive = idFromLink(link) === activeId;
334            link.classList.toggle("active", isActive);
335            if (isActive) {
336                link.setAttribute("aria-current", "true");
337            } else {
338                link.removeAttribute("aria-current");
339            }
340        });
341    }
342
343    function setActive(nextActive) {
344        const activeId = String(nextActive || "");
345        if (state.active === activeId) {
346            updateLinks(activeId);
347            return;
348        }
349
350        state.active = activeId;
351        updateLinks(activeId);
352        window.dispatchEvent(new CustomEvent(eventName, { detail: activeId }));
353    }
354
355    function chooseActive() {
356        const bounds = rootBounds();
357        const visibleSections = sections
358            .filter(function(section) {
359                if (!elementVisible(section)) {
360                    return false;
361                }
362
363                const rect = section.getBoundingClientRect();
364                return rect.bottom > bounds.top && rect.top < bounds.bottom;
365            })
366            .sort(function(a, b) {
367                return a.getBoundingClientRect().top - b.getBoundingClientRect().top;
368            });
369
370        if (visibleSections.length === 0) {
371            return "";
372        }
373
374        let activeSection = visibleSections[0];
375        visibleSections.forEach(function(section) {
376            const rect = section.getBoundingClientRect();
377            if (rect.top - bounds.top <= offset + 1) {
378                activeSection = section;
379            }
380        });
381
382        return activeSection.id || "";
383    }
384
385    function update() {
386        animationFrame = 0;
387        setActive(chooseActive());
388    }
389
390    function scheduleUpdate() {
391        if (animationFrame) {
392            return;
393        }
394        animationFrame = window.requestAnimationFrame(update);
395    }
396
397    function attachSmoothScroll() {
398        if (!smoothScroll) {
399            return;
400        }
401
402        links.forEach(function(link) {
403            const handler = function(event) {
404                const section = sectionFromId(idFromLink(link));
405                if (!section || !elementInScope(section)) {
406                    return;
407                }
408
409                event.preventDefault();
410                const behavior = "smooth";
411                if (rootElement) {
412                    const rootRect = rootElement.getBoundingClientRect();
413                    const sectionRect = section.getBoundingClientRect();
414                    rootElement.scrollTo({
415                        top: rootElement.scrollTop + sectionRect.top - rootRect.top - offset,
416                        behavior
417                    });
418                } else {
419                    const sectionRect = section.getBoundingClientRect();
420                    window.scrollTo({
421                        top: window.scrollY + sectionRect.top - offset,
422                        behavior
423                    });
424                }
425            };
426            link.addEventListener("click", handler);
427            linkCleanups.push(function() {
428                link.removeEventListener("click", handler);
429            });
430        });
431    }
432
433    function refresh() {
434        links = discoverLinks();
435        sections = discoverSections(links);
436
437        if (observer) {
438            observer.disconnect();
439        }
440
441        observer = new IntersectionObserver(scheduleUpdate, {
442            root: rootElement,
443            rootMargin,
444            threshold
445        });
446
447        sections.forEach(function(section) {
448            if (elementVisible(section)) {
449                observer.observe(section);
450            }
451        });
452
453        attachSmoothScroll();
454        scheduleUpdate();
455    }
456
457    const scrollHandler = scheduleUpdate;
458    scrollTarget.addEventListener("scroll", scrollHandler, { passive: true });
459    window.addEventListener("resize", scrollHandler, { passive: true });
460
461    mutationObserver = new MutationObserver(refresh);
462    mutationObserver.observe(rootElement || document.body, {
463        childList: true,
464        subtree: true,
465        attributes: true,
466        attributeFilter: ["id", "href", "data-bs-target", "class", "style", "hidden"]
467    });
468
469    state.cleanup = function() {
470        if (animationFrame) {
471            window.cancelAnimationFrame(animationFrame);
472        }
473        if (observer) {
474            observer.disconnect();
475        }
476        if (mutationObserver) {
477            mutationObserver.disconnect();
478        }
479        scrollTarget.removeEventListener("scroll", scrollHandler);
480        window.removeEventListener("resize", scrollHandler);
481        linkCleanups.forEach(function(cleanup) {
482            cleanup();
483        });
484        delete window.__dbcssScrollspy[id];
485    };
486
487    refresh();
488})();
489"##;
490
491const SCROLLSPY_EVENT_SCRIPT: &str = r##"
492const id = __ID__;
493const last = __LAST__;
494const eventName = "dbcss:scrollspy:" + id;
495
496return new Promise(function(resolve) {
497    const state = window.__dbcssScrollspy && window.__dbcssScrollspy[id];
498    if (state && state.active !== last) {
499        resolve(state.active || "");
500        return;
501    }
502
503    const handler = function(event) {
504        const next = String(event.detail || "");
505        if (next !== last) {
506            window.removeEventListener(eventName, handler);
507            resolve(next);
508        }
509    };
510
511    window.addEventListener(eventName, handler);
512});
513"##;
514
515const SCROLLSPY_CLEANUP_SCRIPT: &str = r##"
516(function() {
517    const id = __ID__;
518    const state = window.__dbcssScrollspy && window.__dbcssScrollspy[id];
519    if (state && typeof state.cleanup === "function") {
520        state.cleanup();
521    }
522})();
523"##;
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn threshold_js_uses_default_for_empty_or_invalid_values() {
531        assert_eq!(threshold_js(&[]), "[0.100,0.500,1.000]");
532        assert_eq!(threshold_js(&[f64::NAN]), "[0.100,0.500,1.000]");
533    }
534
535    #[test]
536    fn threshold_js_clamps_to_intersection_observer_range() {
537        assert_eq!(threshold_js(&[-1.0, 0.25, 2.0]), "[0.000,0.250,1.000]");
538    }
539}