import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { debounce } from "@scandic-hotels/common/utils/debounce" export default function useScrollSpy( sectionIds: string[], options: IntersectionObserverInit = {} ): { activeSectionId: string pauseScrollSpy: () => void } { const [activeSectionId, setActiveSectionId] = useState("") const observerIsInactive = useRef(false) const safetyTimeoutRef = useRef | null>(null) const mergedOptions = useMemo( () => ({ root: null, // Make sure only to activate the section when it reaches the top of the viewport. // A negative value for rootMargin shrinks the root bounding box inward, // meaning elements will only be considered intersecting when they are further inside the viewport. rootMargin: "-15% 0% -85% 0%", threshold: 0, ...options, }), [options] ) const handleIntersection = useCallback( (entries: IntersectionObserverEntry[]) => { if (observerIsInactive.current) { return } const intersectingEntries: IntersectionObserverEntry[] = [] entries.forEach((e) => { if (e.isIntersecting) { intersectingEntries.push(e) } }) if (intersectingEntries.length) { setActiveSectionId(intersectingEntries[0].target.id) } }, [] ) // Scroll event listener to reset the observerIsInactive state // This is to support the pauseScrollSpy function to prevent the observer from firing when scrolling. useEffect(() => { const handleScroll = debounce(() => { if (observerIsInactive.current) { observerIsInactive.current = false } }, 200) window.addEventListener("scroll", handleScroll, { passive: true }) return () => { window.removeEventListener("scroll", handleScroll) } }, []) useEffect(() => { const observer = new IntersectionObserver(handleIntersection, mergedOptions) const elements = sectionIds .map((id) => document.getElementById(id)) .filter((el): el is HTMLElement => !!el) elements.forEach((element) => { observer.observe(element) }) return () => elements.forEach((el) => el && observer.unobserve(el)) }, [sectionIds, mergedOptions, handleIntersection]) function pauseScrollSpy() { observerIsInactive.current = true if (safetyTimeoutRef.current) { clearTimeout(safetyTimeoutRef.current) } // Safety timeout - maximum time to wait before re-enabling safetyTimeoutRef.current = setTimeout(() => { observerIsInactive.current = false safetyTimeoutRef.current = null }, 1500) } return { activeSectionId, pauseScrollSpy } }