60 lines
2.0 KiB
TypeScript
60 lines
2.0 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
|
|
/**
|
|
* Detects whether a sentinel element has scrolled above the top of the
|
|
* nearest scroll container (the AppShell's `main.content-scroll`).
|
|
* Returns `true` when the user has moved past the sentinel, `false`
|
|
* while the sentinel is still in or below the viewport.
|
|
*
|
|
* Uses a ref-callback API rather than a `useRef` so the listener
|
|
* actually attaches when the sentinel mounts. With a plain useRef the
|
|
* setup effect runs once on first commit - which on pages that show a
|
|
* skeleton until data loads is BEFORE the real sentinel div renders,
|
|
* leaving `ref.current` null and the listener never wired up.
|
|
*
|
|
* Usage:
|
|
* const { sentinelRef, past } = usePastSentinel()
|
|
* return <div ref={sentinelRef} />
|
|
*/
|
|
export function usePastSentinel(): {
|
|
sentinelRef: (el: HTMLElement | null) => void
|
|
past: boolean
|
|
} {
|
|
const [el, setEl] = useState<HTMLElement | null>(null)
|
|
const [past, setPast] = useState(false)
|
|
|
|
// Wrap the state setter so React doesn't change the callback identity
|
|
// every render - the `ref` prop sees a stable function.
|
|
const sentinelRef = useCallback((next: HTMLElement | null) => {
|
|
setEl(next)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!el) return
|
|
const root = (document.querySelector('main.content-scroll') as HTMLElement | null) || null
|
|
let raf = 0
|
|
const update = () => {
|
|
raf = 0
|
|
const rect = el.getBoundingClientRect()
|
|
const rootTop = root ? root.getBoundingClientRect().top : 0
|
|
setPast(rect.top <= rootTop + 1)
|
|
}
|
|
const schedule = () => {
|
|
if (raf) return
|
|
raf = requestAnimationFrame(update)
|
|
}
|
|
schedule()
|
|
const target: EventTarget = root || window
|
|
target.addEventListener('scroll', schedule, { passive: true })
|
|
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null
|
|
ro?.observe(el)
|
|
return () => {
|
|
if (raf) cancelAnimationFrame(raf)
|
|
target.removeEventListener('scroll', schedule)
|
|
ro?.disconnect()
|
|
}
|
|
}, [el])
|
|
|
|
return { sentinelRef, past }
|
|
}
|