// TocScrollSpy — small client-side island that highlights the active // TOC link as the user scrolls past the corresponding heading. // // The DocsLayout renders a static TOC; this island runs in the // browser, watches the current h2/h3 headings in the page content, // and toggles `data-active` on the matching `[data-toc-link]`. // Pure side-effect — no UI of its own. // // Why this lives as a separate primitive: the DocsLayout itself // stays SSR-safe and stateless; the scroll spy needs window APIs. // Apps render this once near the top of their page tree (inside the // layout, but conditionally — only mount when there IS a TOC). import { useEffect, type ReactNode } from 'react' interface TocScrollSpyProps { /** CSS selector for the content container holding the headings. * Defaults to `main` since the layout's content lives there. */ readonly contentSelector?: string } export const TocScrollSpy = ({ contentSelector = 'main' }: TocScrollSpyProps): ReactNode => { useEffect(() => { const main = document.querySelector(contentSelector) if (!main) return let headings: HTMLElement[] = [] let frame: number | null = null let active: string | null = null const tocLinks = (): HTMLElement[] => Array.from(document.querySelectorAll('[data-toc-link]')) const tocLinkFor = (id: string): HTMLElement | null => tocLinks().find((link) => link.dataset.tocLink === id) ?? null const setActive = (id: string | null): void => { if (id === active) return for (const prev of tocLinks()) { prev?.removeAttribute('data-active') prev?.removeAttribute('aria-current') } active = id if (id) { const next = tocLinkFor(id) next?.setAttribute('data-active', 'true') next?.setAttribute('aria-current', 'location') } } const updateActive = (): void => { frame = null if (headings.length === 0) { setActive(null) return } // A stable reading line just below the sticky header. Pick the // last heading that has crossed it; before the first heading, keep // the first TOC item active so the sidebar never looks inert. const readingLine = Math.min(180, Math.max(96, window.innerHeight * 0.24)) let current = headings[0]?.id ?? null for (const heading of headings) { if (heading.getBoundingClientRect().top <= readingLine) { current = heading.id } else { break } } const scrollBottom = window.scrollY + window.innerHeight const docBottom = document.documentElement.scrollHeight if (docBottom - scrollBottom < 2) current = headings[headings.length - 1]?.id ?? current setActive(current) } const scheduleUpdate = (): void => { if (frame !== null) return frame = window.requestAnimationFrame(updateActive) } const collectHeadings = (): void => { headings = Array.from(main.querySelectorAll('h2[id], h3[id]')) active = null scheduleUpdate() } collectHeadings() const observer = new MutationObserver(collectHeadings) observer.observe(main, { childList: true, subtree: true }) window.addEventListener('scroll', scheduleUpdate, { passive: true }) window.addEventListener('resize', scheduleUpdate) window.addEventListener('hashchange', scheduleUpdate) return () => { observer.disconnect() window.removeEventListener('scroll', scheduleUpdate) window.removeEventListener('resize', scheduleUpdate) window.removeEventListener('hashchange', scheduleUpdate) if (frame !== null) window.cancelAnimationFrame(frame) } }, [contentSelector]) return null }