'use client' import * as React from 'react' import { mergeProps } from '@base-ui/react/merge-props' import { useRender } from '@base-ui/react/use-render' import { cn } from '../../internal/utils' type TocContextValue = { activeIds: readonly string[] currentId: string | null register: (id: string, element: HTMLElement) => () => void getLinkElement: (id: string) => HTMLElement | undefined } const TocContext = React.createContext(null) function useTocContext(component: string): TocContextValue { const ctx = React.useContext(TocContext) if (!ctx) throw new Error(`Appica UI: <${component}> must be used within .`) return ctx } function areSameIds(a: readonly string[], b: readonly string[]) { return a.length === b.length && a.every((id, index) => id === b[index]) } function closestHeadingId(ids: readonly string[], rootBounds: DOMRectReadOnly | null) { if (!rootBounds) return null let closest: string | null = null let minDistance = Number.POSITIVE_INFINITY for (const id of ids) { const heading = document.getElementById(id) if (!heading) continue const distance = Math.abs(heading.getBoundingClientRect().top - rootBounds.top) if (distance < minDistance) { minDistance = distance closest = id } } return closest } interface TocProps extends React.ComponentPropsWithoutRef<'nav'> { /** * `IntersectionObserver` `rootMargin` (`top right bottom left`) - offset the active boundary, e.g. for a sticky * header. * @default '0px' */ rootMargin?: string } function Toc({ className, rootMargin = '0px', ...props }: TocProps) { const [ids, setIds] = React.useState([]) const [activeIds, setActiveIds] = React.useState([]) const linkElementsRef = React.useRef(new Map()) const register = React.useCallback((id: string, element: HTMLElement) => { linkElementsRef.current.set(id, element) setIds((prev) => (prev.includes(id) ? prev : [...prev, id])) return () => { linkElementsRef.current.delete(id) setIds((prev) => prev.filter((existing) => existing !== id)) } }, []) const getLinkElement = React.useCallback((id: string) => linkElementsRef.current.get(id), []) React.useEffect(() => { if (ids.length === 0) { setActiveIds((prev) => (prev.length === 0 ? prev : [])) return } if (typeof IntersectionObserver === 'undefined') return const visible = new Set() const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) visible.add(entry.target.id) else visible.delete(entry.target.id) } let next = ids.filter((id) => visible.has(id)) if (next.length === 0) { const fallback = closestHeadingId(ids, entries[0]?.rootBounds ?? null) if (fallback) next = [fallback] } setActiveIds((prev) => (areSameIds(prev, next) ? prev : next)) }, { rootMargin }, ) for (const id of ids) { const heading = document.getElementById(id) if (heading) observer.observe(heading) } return () => observer.disconnect() }, [ids, rootMargin]) const ctx = React.useMemo( () => ({ activeIds, currentId: activeIds[0] ?? null, register, getLinkElement }), [activeIds, register, getLinkElement], ) return (