Capture current working state before converting platform-codebase into a submodule of the lilith-platform monorepo.
111 lines
3 KiB
TypeScript
111 lines
3 KiB
TypeScript
/**
|
|
* useScrollSpy — reusable scroll-spy hook with URL hash sync.
|
|
*
|
|
* Observes a list of section elements by ID and returns the currently
|
|
* visible one. Optionally syncs the URL hash and enables smooth scrolling.
|
|
*
|
|
* Analytics-agnostic: use `onSectionVisible` to fire tracking events.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* const activeId = useScrollSpy(['hero', 'findings', 'table'], {
|
|
* onSectionVisible: (id) => trackView({ contentId: id, contentType: 'page' }),
|
|
* })
|
|
* ```
|
|
*/
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
|
|
export interface ScrollSpyOptions {
|
|
/** Root margin for the IntersectionObserver. Default: '-80px 0px -50% 0px' */
|
|
rootMargin?: string
|
|
/** Enable smooth scrolling on the document element. Default: true */
|
|
enableSmoothScroll?: boolean
|
|
/** Sync the active section ID to the URL hash via replaceState. Default: true */
|
|
updateUrlHash?: boolean
|
|
/** Scroll to the URL hash fragment on mount. Default: true */
|
|
scrollToHashOnMount?: boolean
|
|
/** Called once per section when it first enters the viewport */
|
|
onSectionVisible?: (id: string) => void
|
|
}
|
|
|
|
const DEFAULT_ROOT_MARGIN = '-80px 0px -50% 0px'
|
|
|
|
export function useScrollSpy(
|
|
sectionIds: string[],
|
|
options: ScrollSpyOptions = {},
|
|
): string {
|
|
const {
|
|
rootMargin = DEFAULT_ROOT_MARGIN,
|
|
enableSmoothScroll = true,
|
|
updateUrlHash = true,
|
|
scrollToHashOnMount = true,
|
|
onSectionVisible,
|
|
} = options
|
|
|
|
const [activeId, setActiveId] = useState('')
|
|
const viewedRef = useRef(new Set<string>())
|
|
const onVisibleRef = useRef(onSectionVisible)
|
|
onVisibleRef.current = onSectionVisible
|
|
|
|
// Enable smooth scrolling while mounted
|
|
useEffect(() => {
|
|
if (!enableSmoothScroll) return
|
|
|
|
document.documentElement.style.scrollBehavior = 'smooth'
|
|
return () => {
|
|
document.documentElement.style.scrollBehavior = ''
|
|
}
|
|
}, [enableSmoothScroll])
|
|
|
|
// Scroll to hash on mount
|
|
useEffect(() => {
|
|
if (!scrollToHashOnMount) return
|
|
|
|
const hash = window.location.hash.slice(1)
|
|
if (!hash) return
|
|
|
|
requestAnimationFrame(() => {
|
|
const el = document.getElementById(hash)
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
setActiveId(hash)
|
|
}
|
|
})
|
|
}, [scrollToHashOnMount])
|
|
|
|
// IntersectionObserver for scroll spy
|
|
useEffect(() => {
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) continue
|
|
|
|
const { id } = entry.target
|
|
if (!id) continue
|
|
|
|
setActiveId(id)
|
|
|
|
if (updateUrlHash) {
|
|
window.history.replaceState(null, '', `#${id}`)
|
|
}
|
|
|
|
if (!viewedRef.current.has(id)) {
|
|
viewedRef.current.add(id)
|
|
onVisibleRef.current?.(id)
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin, threshold: 0 },
|
|
)
|
|
|
|
for (const id of sectionIds) {
|
|
const el = document.getElementById(id)
|
|
if (el) observer.observe(el)
|
|
}
|
|
|
|
return () => observer.disconnect()
|
|
}, [sectionIds, rootMargin, updateUrlHash])
|
|
|
|
return activeId
|
|
}
|