Skip to main content

dioxus_mdx/components/
toc.rs

1//! Table of contents component for documentation pages.
2//!
3//! Features:
4//! - Displays page headers in a sidebar navigation
5//! - Tracks scroll position and highlights the current section
6//! - Uses IntersectionObserver for performant scroll tracking
7
8use dioxus::prelude::*;
9use dioxus_free_icons::{Icon, icons::ld_icons::LdList};
10
11/// Props for DocTableOfContents component.
12#[derive(Props, Clone, PartialEq)]
13pub struct DocTableOfContentsProps {
14    /// List of headers: (id, title, level).
15    pub headers: Vec<(String, String, u8)>,
16}
17
18/// Table of contents sidebar component with scroll tracking.
19///
20/// Scroll tracking is handled client-side via JavaScript for performance.
21/// The component uses data attributes and CSS for active state styling.
22#[component]
23pub fn DocTableOfContents(props: DocTableOfContentsProps) -> Element {
24    // Extract header IDs for the observer
25    #[allow(unused_variables)]
26    let header_ids: Vec<String> = props.headers.iter().map(|(id, _, _)| id.clone()).collect();
27
28    // Set up IntersectionObserver to track visible sections (client-side only)
29    #[cfg(target_arch = "wasm32")]
30    {
31        let header_ids_for_effect = header_ids.clone();
32        use_effect(move || {
33            let ids = header_ids_for_effect.clone();
34            if ids.is_empty() {
35                return;
36            }
37
38            // Set up IntersectionObserver and scroll listener via JavaScript
39            // Uses data-toc-link attributes to find and update TOC links
40            let js = format!(
41                r#"
42                (function() {{
43                    const ids = {};
44
45                    // Update active TOC item
46                    function setActiveTocItem(activeId) {{
47                        // Remove active class from all TOC links
48                        document.querySelectorAll('[data-toc-link]').forEach(link => {{
49                            link.classList.remove('toc-active');
50                            link.classList.add('toc-inactive');
51                        }});
52
53                        // Add active class to the current link
54                        if (activeId) {{
55                            const activeLink = document.querySelector(`[data-toc-link="${{activeId}}"]`);
56                            if (activeLink) {{
57                                activeLink.classList.remove('toc-inactive');
58                                activeLink.classList.add('toc-active');
59                            }}
60                        }}
61                    }}
62
63                    // Find the currently active heading based on scroll position
64                    function updateActiveHeading() {{
65                        let activeId = null;
66                        const scrollPos = window.scrollY + 100; // Offset for fixed header
67
68                        for (const id of ids) {{
69                            const el = document.getElementById(id);
70                            if (el) {{
71                                const rect = el.getBoundingClientRect();
72                                const absoluteTop = rect.top + window.scrollY;
73                                if (absoluteTop <= scrollPos) {{
74                                    activeId = id;
75                                }}
76                            }}
77                        }}
78
79                        setActiveTocItem(activeId);
80                    }}
81
82                    // Debounce scroll handler
83                    let scrollTimeout;
84                    function handleScroll() {{
85                        clearTimeout(scrollTimeout);
86                        scrollTimeout = setTimeout(updateActiveHeading, 10);
87                    }}
88
89                    // Set up scroll listener
90                    window.addEventListener('scroll', handleScroll, {{ passive: true }});
91
92                    // Initial update
93                    setTimeout(updateActiveHeading, 100);
94
95                    // Store cleanup function
96                    window.tocCleanup = () => {{
97                        window.removeEventListener('scroll', handleScroll);
98                    }};
99                }})();
100                "#,
101                serde_json::to_string(&ids).unwrap_or_default()
102            );
103
104            // Run the JavaScript
105            spawn(async move {
106                let _ = document::eval(&js);
107            });
108        });
109    }
110
111    if props.headers.is_empty() {
112        return rsx! {};
113    }
114
115    rsx! {
116        nav { class: "text-sm",
117            h4 { class: "font-semibold text-base-content mb-4 text-xs uppercase tracking-wider flex items-center gap-1.5",
118                Icon { class: "size-3.5", icon: LdList }
119                "On this page"
120            }
121            ul { class: "space-y-2.5",
122                for (i, (id, title, level)) in props.headers.iter().enumerate() {
123                    TocItem {
124                        key: "{i}",
125                        id: id.clone(),
126                        title: title.clone(),
127                        level: *level,
128                    }
129                }
130            }
131        }
132        // CSS for active/inactive states (injected once)
133        style {
134            r#"
135            .toc-active {{
136                color: oklch(var(--p)) !important;
137                font-weight: 500;
138            }}
139            .toc-active::before {{
140                content: '';
141                position: absolute;
142                left: -14px;
143                top: 50%;
144                transform: translateY(-50%);
145                width: 3px;
146                height: 18px;
147                background: oklch(var(--p));
148                border-radius: 9999px;
149                transition: all 0.15s ease-out;
150            }}
151            .toc-inactive {{
152                color: oklch(var(--bc) / 0.55);
153                transition: color 0.15s ease-out;
154            }}
155            .toc-inactive:hover {{
156                color: oklch(var(--bc) / 0.9);
157            }}
158            "#
159        }
160    }
161}
162
163/// Props for TocItem.
164#[derive(Props, Clone, PartialEq)]
165struct TocItemProps {
166    id: String,
167    title: String,
168    level: u8,
169}
170
171/// Individual TOC item.
172#[component]
173fn TocItem(props: TocItemProps) -> Element {
174    let (indent_class, text_class) = match props.level {
175        2 => ("", ""),
176        3 => ("ml-4", "text-[13px]"),
177        _ => ("ml-6", "text-xs"),
178    };
179
180    rsx! {
181        li {
182            class: "{indent_class} relative",
183            a {
184                href: "#{props.id}",
185                class: "toc-inactive block py-0.5 {text_class}",
186                "data-toc-link": "{props.id}",
187                onclick: move |evt| {
188                    evt.prevent_default();
189                    // Smooth scroll to the heading (client-side only)
190                    #[cfg(target_arch = "wasm32")]
191                    {
192                        let id = props.id.clone();
193                        spawn(async move {
194                            let js = format!(
195                                r#"
196                                const el = document.getElementById({});
197                                if (el) {{
198                                    el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
199                                    // Update URL hash without jumping
200                                    history.pushState(null, '', '#' + {});
201                                }}
202                                "#,
203                                serde_json::to_string(&id).unwrap_or_default(),
204                                serde_json::to_string(&id).unwrap_or_default()
205                            );
206                            let _ = document::eval(&js);
207                        });
208                    }
209                },
210                "{props.title}"
211            }
212        }
213    }
214}
215
216/// Extract headers from markdown content for table of contents.
217pub fn extract_headers(content: &str) -> Vec<(String, String, u8)> {
218    let mut headers = Vec::new();
219    let heading_re = regex::Regex::new(r"(?m)^(#{2,4})\s+(.+)$").unwrap();
220
221    for caps in heading_re.captures_iter(content) {
222        let level = caps[1].len() as u8;
223        let title = caps[2].trim().to_string();
224        let id = slugify(&title);
225        headers.push((id, title, level));
226    }
227
228    headers
229}
230
231/// Convert a title to a URL-friendly slug.
232pub fn slugify(text: &str) -> String {
233    text.to_lowercase()
234        .chars()
235        .filter_map(|c| {
236            if c.is_alphanumeric() {
237                Some(c)
238            } else if c.is_whitespace() || c == '-' || c == '_' || c == '.' {
239                Some('-')
240            } else {
241                None
242            }
243        })
244        .collect::<String>()
245        .split('-')
246        .filter(|s| !s.is_empty())
247        .collect::<Vec<_>>()
248        .join("-")
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_extract_headers() {
257        let content = r#"
258## Introduction
259
260Some text.
261
262### Getting Started
263
264More text.
265
266## Configuration
267
268### Advanced Options
269"#;
270
271        let headers = extract_headers(content);
272        assert_eq!(headers.len(), 4);
273        assert_eq!(
274            headers[0],
275            ("introduction".to_string(), "Introduction".to_string(), 2)
276        );
277        assert_eq!(
278            headers[1],
279            (
280                "getting-started".to_string(),
281                "Getting Started".to_string(),
282                3
283            )
284        );
285        assert_eq!(
286            headers[2],
287            ("configuration".to_string(), "Configuration".to_string(), 2)
288        );
289        assert_eq!(
290            headers[3],
291            (
292                "advanced-options".to_string(),
293                "Advanced Options".to_string(),
294                3
295            )
296        );
297    }
298
299    #[test]
300    fn test_slugify() {
301        assert_eq!(slugify("Hello World"), "hello-world");
302        assert_eq!(slugify("Getting Started!"), "getting-started");
303        assert_eq!(slugify("API v1.0"), "api-v1-0");
304    }
305}