dioxus_bootstrap_css/scrollspy.rs
1use dioxus::prelude::*;
2
3/// Bootstrap Scrollspy — tracks scroll position and updates active signal.
4///
5/// Place this component in your layout. It watches the given target element
6/// (by CSS selector) and updates the `active` signal with the `id` of the
7/// currently visible section.
8///
9/// # Bootstrap HTML → Dioxus
10///
11/// | HTML | Dioxus |
12/// |---|---|
13/// | `<body data-bs-spy="scroll" data-bs-target="#nav" data-bs-offset="80">` | `Scrollspy { target: "body", active: signal, offset: 80 }` |
14/// | Check active section via JS | `if *active.read() == "intro" { "active" }` |
15///
16/// ```rust
17/// let active_section = use_signal(|| String::new());
18/// rsx! {
19/// Scrollspy { target: "main", active: active_section, offset: 80 }
20/// nav {
21/// a { class: if *active_section.read() == "intro" { "nav-link active" } else { "nav-link" },
22/// href: "#intro", "Intro" }
23/// a { class: if *active_section.read() == "features" { "nav-link active" } else { "nav-link" },
24/// href: "#features", "Features" }
25/// }
26/// }
27/// ```
28#[derive(Clone, PartialEq, Props)]
29pub struct ScrollspyProps {
30 /// CSS selector for the scrollable container (e.g., "main", "#content", "body").
31 #[props(default = "body".to_string())]
32 pub target: String,
33 /// Signal that receives the `id` of the currently active section.
34 pub active: Signal<String>,
35 /// Offset in pixels from the top (useful for fixed/sticky navbars).
36 #[props(default = 0)]
37 pub offset: i32,
38}
39
40#[component]
41pub fn Scrollspy(props: ScrollspyProps) -> Element {
42 let mut active_signal = props.active;
43 let target = props.target.clone();
44 let offset = props.offset;
45
46 // Set up IntersectionObserver via eval to track which [id] section is visible
47 use_effect(move || {
48 let target = target.clone();
49 document::eval(&format!(
50 r#"
51 (function() {{
52 var container = document.querySelector('{target}');
53 if (!container || container === document.body) container = document;
54 var sections = document.querySelectorAll('[id]');
55 if (sections.length === 0) return;
56
57 function update() {{
58 var scrollTop = (container === document)
59 ? window.scrollY || document.documentElement.scrollTop
60 : container.scrollTop;
61 var offset = {offset};
62 var active = '';
63 sections.forEach(function(section) {{
64 var rect = section.getBoundingClientRect();
65 if (rect.top <= offset + 10) {{
66 active = section.id;
67 }}
68 }});
69 if (active && window.__dioxus_scrollspy_active !== active) {{
70 window.__dioxus_scrollspy_active = active;
71 // Dispatch a custom event that Dioxus can listen to
72 window.dispatchEvent(new CustomEvent('scrollspy', {{ detail: active }}));
73 }}
74 }}
75
76 var scrollTarget = (container === document) ? window : container;
77 scrollTarget.addEventListener('scroll', update, {{ passive: true }});
78 update();
79 }})();
80 "#
81 ));
82 });
83
84 // Listen for scrollspy events via polling with eval
85 // Note: This uses a simple approach — the JS side updates a global,
86 // and we read it periodically via use_effect
87 use_effect(move || {
88 let eval_handle = document::eval(
89 r#"
90 (function() {
91 return new Promise(function(resolve) {
92 var current = window.__dioxus_scrollspy_active || '';
93 // Set up listener for changes
94 window.addEventListener('scrollspy', function handler(e) {
95 resolve(e.detail);
96 window.removeEventListener('scrollspy', handler);
97 });
98 // If already set, resolve immediately
99 if (current) resolve(current);
100 });
101 })()
102 "#,
103 );
104
105 spawn(async move {
106 if let Ok(value) = eval_handle.await {
107 if let Some(id) = value.as_str() {
108 active_signal.set(id.to_string());
109 }
110 }
111 });
112 });
113
114 rsx! {}
115}