59 lines
2.8 KiB
TypeScript
59 lines
2.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
/** The nearest ancestor that scrolls vertically (the app's content pane), or the document. */
|
|
function scrollParent(el: HTMLElement | null): HTMLElement | null {
|
|
for (let p = el?.parentElement ?? null; p; p = p.parentElement) {
|
|
const o = getComputedStyle(p).overflowY;
|
|
if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Keeps a section nav in step with scrolling: the active id is the last section whose top has passed the upper
|
|
* third of the scroll area, or the last section once the area is scrolled to the end. `jump` scrolls to a section
|
|
* and holds the highlight on it until that scroll settles, so the nav doesn't flicker through the sections in between.
|
|
*/
|
|
export function useScrollSpy(ids: readonly string[], prefix: string, initial: string, smooth: boolean) {
|
|
const [active, setActive] = useState(initial);
|
|
const hold = useRef<{ id: string; until: number } | null>(null);
|
|
const key = ids.join('|');
|
|
|
|
useEffect(() => {
|
|
const first = document.getElementById(`${prefix}${ids[0]}`);
|
|
const root = scrollParent(first);
|
|
const target: HTMLElement | Window = root ?? window;
|
|
let frame = 0;
|
|
const update = (): void => {
|
|
frame = 0;
|
|
const h = hold.current;
|
|
if (h && performance.now() < h.until) return;
|
|
hold.current = null;
|
|
const top = root ? root.getBoundingClientRect().top : 0;
|
|
const height = root ? root.clientHeight : window.innerHeight;
|
|
const atEnd = root ? root.scrollTop + root.clientHeight >= root.scrollHeight - 2 : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
|
|
let current = ids[0]!;
|
|
if (atEnd) current = ids[ids.length - 1]!;
|
|
else for (const id of ids) {
|
|
const el = document.getElementById(`${prefix}${id}`);
|
|
if (el && el.getBoundingClientRect().top - top <= height / 3) current = id;
|
|
}
|
|
setActive(current);
|
|
};
|
|
const onScroll = (): void => { if (!frame) frame = requestAnimationFrame(update); };
|
|
const onEnd = (): void => { hold.current = null; onScroll(); };
|
|
target.addEventListener('scroll', onScroll, { passive: true });
|
|
target.addEventListener('scrollend', onEnd);
|
|
update();
|
|
return () => { target.removeEventListener('scroll', onScroll); target.removeEventListener('scrollend', onEnd); if (frame) cancelAnimationFrame(frame); };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key, prefix]);
|
|
|
|
const jump = useCallback((id: string, opts?: { instant?: boolean }) => {
|
|
setActive(id);
|
|
hold.current = { id, until: performance.now() + 1200 };
|
|
document.getElementById(`${prefix}${id}`)?.scrollIntoView({ block: 'start', behavior: smooth && !opts?.instant ? 'smooth' : 'auto' });
|
|
}, [prefix, smooth]);
|
|
|
|
return { active, jump };
|
|
}
|