{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scrollspy","type":"registry:ui","title":"Scrollspy","description":"","dependencies":[],"registryDependencies":[],"files":[{"path":"scrollspy.tsx","type":"registry:ui","content":"\"use client\"\n\nimport type { ReactNode, RefObject } from \"react\"\nimport { useCallback, useEffect, useRef } from \"react\"\n\ntype ScrollspyProps = {\n children: ReactNode\n targetRef?: RefObject<\n HTMLElement | HTMLDivElement | Document | null | undefined\n >\n onUpdate?: (id: string) => void\n offset?: number\n smooth?: boolean\n className?: string\n dataAttribute?: string\n history?: boolean\n throttleTime?: number\n}\n\nexport function Scrollspy({\n children,\n targetRef,\n onUpdate,\n className,\n offset = 0,\n smooth = true,\n dataAttribute = \"scrollspy\",\n history = true,\n}: ScrollspyProps) {\n const selfRef = useRef(null)\n const anchorElementsRef = useRef(null)\n const prevIdTracker = useRef(null)\n\n // Sets active nav, hash, prevIdTracker, and calls onUpdate\n const setActiveSection = useCallback(\n (sectionId: string | null, force = false) => {\n if (!sectionId) return\n anchorElementsRef.current?.forEach((item) => {\n const id = item.getAttribute(`data-${dataAttribute}-anchor`)\n if (id === sectionId) {\n item.setAttribute(\"data-active\", \"true\")\n } else {\n item.removeAttribute(\"data-active\")\n }\n })\n if (onUpdate) onUpdate(sectionId)\n if (history && (force || prevIdTracker.current !== sectionId)) {\n window.history.replaceState({}, \"\", `#${sectionId}`)\n }\n prevIdTracker.current = sectionId\n },\n [anchorElementsRef, dataAttribute, history, onUpdate]\n )\n\n const handleScroll = useCallback(() => {\n if (!anchorElementsRef.current || anchorElementsRef.current.length === 0)\n return\n\n let scrollElement =\n targetRef?.current === document\n ? document.documentElement\n : (targetRef?.current as HTMLElement)\n\n if (!scrollElement) return\n\n // If the scrollElement has a data-slot=\"scroll-area-viewport\" inside, use that\n const viewport = scrollElement.querySelector(\n '[data-slot=\"scroll-area-viewport\"]'\n )\n if (viewport instanceof HTMLElement) {\n scrollElement = viewport\n }\n\n const scrollTop =\n scrollElement === document.documentElement\n ? window.scrollY || document.documentElement.scrollTop\n : scrollElement.scrollTop\n\n // Find the anchor whose section is closest to but not past the top\n let activeIdx = 0\n let minDelta = Infinity\n\n anchorElementsRef.current.forEach((anchor, idx) => {\n const sectionId = anchor.getAttribute(`data-${dataAttribute}-anchor`)\n const sectionElement = document.getElementById(sectionId!)\n if (!sectionElement) return\n\n let customOffset = offset\n const dataOffset = anchor.getAttribute(`data-${dataAttribute}-offset`)\n if (dataOffset) customOffset = parseInt(dataOffset, 10)\n\n const delta = Math.abs(\n sectionElement.offsetTop - customOffset - scrollTop\n )\n\n if (\n sectionElement.offsetTop - customOffset <= scrollTop &&\n delta < minDelta\n ) {\n minDelta = delta\n activeIdx = idx\n }\n })\n\n // If at bottom, force last anchor\n const scrollHeight = (scrollElement as HTMLElement).scrollHeight\n const clientHeight = (scrollElement as HTMLElement).clientHeight\n\n if (scrollTop + clientHeight >= scrollHeight - 2) {\n activeIdx = anchorElementsRef.current.length - 1\n }\n\n // Set only one anchor active and sync the URL hash\n const activeAnchor = anchorElementsRef.current[activeIdx]\n const sectionId =\n activeAnchor?.getAttribute(`data-${dataAttribute}-anchor`) || null\n\n setActiveSection(sectionId)\n }, [anchorElementsRef, targetRef, dataAttribute, offset, setActiveSection])\n\n const scrollTo = useCallback(\n (anchorElement: HTMLElement) => (event?: Event) => {\n if (event) event.preventDefault()\n const sectionId =\n anchorElement\n .getAttribute(`data-${dataAttribute}-anchor`)\n ?.replace(\"#\", \"\") || null\n if (!sectionId) return\n const sectionElement = document.getElementById(sectionId)\n if (!sectionElement) return\n\n let scrollToElement: HTMLElement | Window | null =\n targetRef?.current === document\n ? window\n : (targetRef?.current as HTMLElement)\n\n if (scrollToElement instanceof HTMLElement) {\n const viewport = scrollToElement.querySelector(\n '[data-slot=\"scroll-area-viewport\"]'\n )\n if (viewport instanceof HTMLElement) {\n scrollToElement = viewport\n }\n }\n\n let customOffset = offset\n const dataOffset = anchorElement.getAttribute(\n `data-${dataAttribute}-offset`\n )\n if (dataOffset) {\n customOffset = parseInt(dataOffset, 10)\n }\n\n const scrollTop = sectionElement.offsetTop - customOffset\n\n if (scrollToElement && \"scrollTo\" in scrollToElement) {\n scrollToElement.scrollTo({\n top: scrollTop,\n left: 0,\n behavior: smooth ? \"smooth\" : \"auto\",\n })\n }\n setActiveSection(sectionId, true)\n },\n [dataAttribute, offset, smooth, targetRef, setActiveSection]\n )\n\n // Scroll to the section if the ID is present in the URL hash\n const scrollToHashSection = useCallback(() => {\n const hash = CSS.escape(window.location.hash.replace(\"#\", \"\"))\n\n if (hash) {\n const targetElement = document.querySelector(\n `[data-${dataAttribute}-anchor=\"${hash}\"]`\n ) as HTMLElement\n if (targetElement) {\n scrollTo(targetElement)()\n }\n }\n }, [dataAttribute, scrollTo])\n\n useEffect(() => {\n // Query elements and store them in the ref, avoiding unnecessary re-renders\n if (selfRef.current) {\n anchorElementsRef.current = Array.from(\n selfRef.current.querySelectorAll(`[data-${dataAttribute}-anchor]`)\n )\n }\n\n const currentAnchors = anchorElementsRef.current\n currentAnchors?.forEach((item) => {\n item.addEventListener(\"click\", scrollTo(item as HTMLElement))\n })\n\n const onScroll = (event: Event) => {\n const scrollElement =\n targetRef?.current === document\n ? window\n : (targetRef?.current as HTMLElement)\n if (!scrollElement) return\n\n if (\n scrollElement === window ||\n (scrollElement instanceof HTMLElement &&\n scrollElement.contains(event.target as Node))\n ) {\n handleScroll()\n }\n }\n\n // Use window listener with capture to catch scroll events from targetRef even if set later\n window.addEventListener(\"scroll\", onScroll, true)\n\n // Check if there's a hash in the URL and scroll to the corresponding section\n const initialTimeout = setTimeout(() => {\n scrollToHashSection()\n handleScroll()\n }, 100)\n\n return () => {\n window.removeEventListener(\"scroll\", onScroll, true)\n currentAnchors?.forEach((item) => {\n item.removeEventListener(\"click\", scrollTo(item as HTMLElement))\n })\n clearTimeout(initialTimeout)\n }\n }, [\n targetRef,\n selfRef,\n handleScroll,\n dataAttribute,\n scrollTo,\n scrollToHashSection,\n ])\n\n return (\n
\n {children}\n
\n )\n}","target":"components/neui/scrollspy.tsx"}]}