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