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#[derive(Clone, PartialEq, Props)]
45pub struct ScrollspyProps {
46 #[props(default = "body".to_string())]
48 pub target: String,
49 #[props(default = "body".to_string())]
51 pub root: String,
52 pub active: Signal<String>,
54 #[props(default = 0)]
56 pub offset: i32,
57 #[props(default = DEFAULT_ROOT_MARGIN.to_string())]
59 pub root_margin: String,
60 #[props(default = DEFAULT_THRESHOLD.to_vec())]
62 pub threshold: Vec<f64>,
63 #[props(default = 0)]
65 pub refresh_key: u64,
66 #[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}