'use client'; import * as React from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { ScrollSpyProvider, useScrollSpyContext } from '../context'; import type { ScrollSpyProps, ScrollSpyNavProps, ScrollSpyNavItemProps, ScrollSpySectionProps, } from '../types'; export function ScrollSpy({ children, offset = 0, rootMargin = '0px 0px -80% 0px', onActiveChange, }: ScrollSpyProps) { const [activeId, setActiveId] = React.useState(null); const sectionsRef = React.useRef>(new Map()); const observerRef = React.useRef(null); const registerSection = React.useCallback((id: string, label: string) => { sectionsRef.current.set(id, label); }, []); const unregisterSection = React.useCallback((id: string) => { sectionsRef.current.delete(id); }, []); const handleSetActiveId = React.useCallback( (id: string) => { setActiveId(id); onActiveChange?.(id); }, [onActiveChange] ); React.useEffect(() => { const observer = new IntersectionObserver( (entries) => { const visible = entries .filter((e) => e.isIntersecting) .sort((a, b) => b.intersectionRatio - a.intersectionRatio); if (visible.length > 0) { handleSetActiveId(visible[0].target.id); } }, { rootMargin, threshold: [0, 0.25, 0.5, 0.75, 1], } ); observerRef.current = observer; // Observe all registered section elements const sectionIds = Array.from(sectionsRef.current.keys()); sectionIds.forEach((id) => { const el = document.getElementById(id); if (el) observer.observe(el); }); return () => observer.disconnect(); }, [rootMargin, handleSetActiveId]); // Re-observe when sections change React.useEffect(() => { const observer = observerRef.current; if (!observer) return; observer.disconnect(); const sectionIds = Array.from(sectionsRef.current.keys()); sectionIds.forEach((id) => { const el = document.getElementById(id); if (el) observer.observe(el); }); }); const sections = React.useMemo( () => Array.from(sectionsRef.current.entries()).map(([id, label]) => ({ id, label, })), [activeId] // recompute when active changes to keep in sync ); const value = React.useMemo( () => ({ activeId, sections, registerSection, unregisterSection, setActiveId: handleSetActiveId, }), [activeId, sections, registerSection, unregisterSection, handleSetActiveId] ); return {children}; } ScrollSpy.displayName = 'ScrollSpy'; export function ScrollSpyNav({ children, className, ...props }: ScrollSpyNavProps) { return ( ); } ScrollSpyNav.displayName = 'ScrollSpyNav'; export function ScrollSpyNavItem({ sectionId, children, className, onClick, ...props }: ScrollSpyNavItemProps) { const { activeId } = useScrollSpyContext(); const isActive = activeId === sectionId; const handleClick = React.useCallback( (e: React.MouseEvent) => { e.preventDefault(); const el = document.getElementById(sectionId); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } onClick?.(e); }, [sectionId, onClick] ); return ( {children} ); } ScrollSpyNavItem.displayName = 'ScrollSpyNavItem'; export function ScrollSpySection({ id, label, children, className, ...props }: ScrollSpySectionProps) { const { registerSection, unregisterSection } = useScrollSpyContext(); React.useEffect(() => { registerSection(id, label); return () => unregisterSection(id); }, [id, label, registerSection, unregisterSection]); return (
{children}
); } ScrollSpySection.displayName = 'ScrollSpySection';