storebound/apps/desktop/src/ui/useScrollSpy.ts

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 };
}