// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useScrollSpy.ts * @input Uses React, scroll position of heading elements, OutlineItem type * @output Exports internal useScrollSpy hook * @position Internal behavior hook; consumed by Outline.tsx * * Drives the active outline item from scroll position. On each scroll * (rAF-throttled) it reads live heading positions and marks the last heading * whose top has passed its activation line. This is stable — it never compares * stale cached positions — so the indicator moves monotonically instead of * jumping. Defaults to the first item at the top and the last item at the * bottom so short final sections still activate. * * The activation line and the scroll landing are the same number, computed once * in `getRestingTop`: scroll-root top + `offset` (a fixed header overlaying the * root) + the heading's own `scroll-margin-top`. A heading therefore activates * exactly where navigating to it puts it. `offset` and `scroll-margin-top` * compose — the header, then the breathing room below it — they do not * duplicate each other. * * It also owns the single navigation path (`scrollTo`), shared by click and * keyboard activation: it announces the jump, suppresses scroll-spy for the * duration of the programmatic scroll (so the indicator doesn't chase it), and * resolves when the scroll settles — via `scrollend` where supported, or a * timeout fallback where it is not. * * The scroll root is the `scrollContainerRef` element when provided, otherwise * the nearest scrollable ancestor of the outline, otherwise the viewport. * * SYNC: When modified, update /packages/core/src/Outline/Outline.tsx */ import { useCallback, useEffect, useRef, useState } from 'react'; import type { OutlineItem } from "./types.js"; /** * How long to wait for a programmatic smooth scroll to settle when the * `scrollend` event never arrives — either because the browser does not * support it (Safari at time of writing) or because the target was already in * position, so no scroll happened at all and no events were emitted. */ const SCROLL_SETTLE_TIMEOUT_MS = 1200; /** Keys that scroll the viewport — used to detect a manual scroll intent. */ const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' ', 'Spacebar']); function getScrollableAncestor(element: HTMLElement | null): HTMLElement | null { let current = element?.parentElement ?? null; while (current != null) { const computedStyle = window.getComputedStyle(current); const overflowY = computedStyle.overflowY; const isScrollable = (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') && current.scrollHeight > current.clientHeight; if (isScrollable) { return current; } current = current.parentElement; } return null; } /** The element's own `scroll-margin-top` in px (0 when unset). */ function getScrollMarginTop(element: HTMLElement): number { return Number.parseFloat(window.getComputedStyle(element).scrollMarginTop) || 0; } /** The top of the scroll root in viewport coordinates (0 for the viewport). */ function getRootTop(scrollRoot: HTMLElement | null): number { return scrollRoot != null ? scrollRoot.getBoundingClientRect().top : 0; } /** * Where a heading comes to rest, in viewport coordinates: below the fixed * header (`offset`) and below its own `scroll-margin-top` breathing room. * * This is the single source of truth shared by the activation line and the * scroll landing — the two must agree, or a heading activates somewhere other * than where navigating to it puts it. */ function getRestingTop(target: HTMLElement, scrollRoot: HTMLElement | null, offset: number): number { return getRootTop(scrollRoot) + offset + getScrollMarginTop(target); } /** * Bring `target` to rest at the top of the scroll root, below any fixed header. * * With no `offset`, this is the CSS-native path: `scrollIntoView` already * honors the heading's `scroll-margin-top`, and it walks *every* scrollable * ancestor, so the browser does it better than we can. * * An `offset` describes a fixed header overlaying the top of the scroll root — * which the browser cannot know about, so `scrollIntoView` would park the * heading underneath it, hidden. Compute the landing explicitly instead, from * the same {@link getRestingTop} the activation line uses. */ function scrollToTarget(target: HTMLElement, scrollRoot: HTMLElement | null, scrollTarget: HTMLElement | Window, offset: number): void { if (offset === 0) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } const delta = target.getBoundingClientRect().top - getRestingTop(target, scrollRoot, offset); scrollTarget.scrollBy({ top: delta, behavior: 'smooth' }); } /** * Resolve the active heading id from current scroll position. * * A heading is "passed" once its top reaches its activation line — exactly * where navigating to it would land it ({@link getRestingTop}): the scroll * root's top, plus `offset` (a fixed header overlaying the scroll root), plus * the heading's own scroll-margin-top. The active heading is the last passed * one (headings are in document order). When none have passed (scrolled above * the first), the first item is active; at the bottom, the last item is active. */ function resolveActiveId(items: OutlineItem[], scrollRoot: HTMLElement | null, offset: number): string | undefined { if (items.length === 0) { return undefined; } const atBottom = scrollRoot != null ? scrollRoot.scrollTop + scrollRoot.clientHeight >= scrollRoot.scrollHeight - 2 : window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2; if (atBottom) { return items[items.length - 1].id; } let activeId = items[0].id; for (const item of items) { const element = document.getElementById(item.id); if (element == null) { continue; } // 1px of tolerance absorbs sub-pixel rounding after a scroll lands. const top = element.getBoundingClientRect().top; if (top <= getRestingTop(element, scrollRoot, offset) + 1) { activeId = item.id; } else { break; } } return activeId; } interface UseScrollSpyOptions { activeId?: string; items: OutlineItem[]; onActiveIdChange?: (id: string) => void; rootRef: React.RefObject; /** * Height in px of a fixed header overlaying the top of the scroll root. * Shifts both the activation line and the scroll landing by the same amount, * on top of each heading's own `scroll-margin-top`. */ offset?: number; /** Scroll container to track, instead of the nearest scrollable ancestor. */ scrollContainerRef?: React.RefObject; /** Whether {@link UseScrollSpyResult.scrollTo} performs the smooth scroll. */ hasScrollOnClick?: boolean; /** Called when a navigation begins, before the scroll starts. */ onNavigateStart?: (id: string) => void; /** Called once per navigation, when the scroll settles or is interrupted. */ onNavigateEnd?: (id: string) => void; } interface UseScrollSpyResult { activeId: string | undefined; /** Set the active id (notifies onActiveIdChange). For controlled consumers. */ setActiveId: (id: string) => void; /** * Navigate to the item with id `id` — the single path shared by click and * keyboard activation. * * Fires `onNavigateStart(id)`, scrolls (unless `hasScrollOnClick` is false), * and fires `onNavigateEnd(id)` exactly once when the scroll settles or the * user interrupts it. While uncontrolled, scroll-spy is suppressed for the * duration so the indicator doesn't chase the scroll through intervening * sections; it lands on the target once the scroll settles, or resumes * position tracking if the user scrolls away mid-flight. * * Returns false (and does nothing) when no element with `id` exists. */ scrollTo: (id: string) => boolean; } export function useScrollSpy({ activeId, items, onActiveIdChange, rootRef, offset = 0, scrollContainerRef, hasScrollOnClick = true, onNavigateStart, onNavigateEnd }: UseScrollSpyOptions): UseScrollSpyResult { const isControlled = activeId !== undefined; const [uncontrolledActiveId, setUncontrolledActiveId] = useState(items[0]?.id); const activeIdRef = useRef(activeId); // While true, scroll-spy ignores scroll updates because a navigation is // driving a programmatic scroll. Released when it settles or the user scrolls. const suppressRef = useRef(false); // The navigation currently in flight, if any. const navigationRef = useRef<{ /** End it (without resuming tracking) because a new one is starting. */ supersede: () => void; /** Drop its listeners without firing onNavigateEnd (unmount). */ teardown: () => void; } | null>(null); // Latest scroll-position resolver, so a navigation can resume tracking when // the user scrolls during the programmatic scroll. const syncRef = useRef<(() => void) | null>(null); // Keep latest items/callback in refs so the scroll listener effect doesn't // re-subscribe on every render (items is a fresh array each render). const itemsRef = useRef(items); itemsRef.current = items; const onActiveIdChangeRef = useRef(onActiveIdChange); onActiveIdChangeRef.current = onActiveIdChange; const itemIds = items.map(item => item.id).join('\n'); activeIdRef.current = isControlled ? activeId : uncontrolledActiveId; /** * The element whose scroll position drives the outline: the explicit * container when scoped, else the nearest scrollable ancestor, else null * (meaning the viewport). */ const getScrollRoot = useCallback((): HTMLElement | null => { return scrollContainerRef?.current ?? getScrollableAncestor(rootRef.current); }, [rootRef, scrollContainerRef]); useEffect(() => { if (isControlled || typeof window === 'undefined') { return; } const scrollRoot = getScrollRoot(); const scrollTarget: HTMLElement | Window = scrollRoot ?? window; let frame = 0; const update = () => { frame = 0; if (suppressRef.current) { return; } const nextActiveId = resolveActiveId(itemsRef.current, scrollRoot, offset); if (nextActiveId != null && nextActiveId !== activeIdRef.current) { activeIdRef.current = nextActiveId; setUncontrolledActiveId(nextActiveId); onActiveIdChangeRef.current?.(nextActiveId); } }; const onScroll = () => { if (frame === 0) { frame = requestAnimationFrame(update); } }; syncRef.current = update; update(); scrollTarget.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onScroll, { passive: true }); return () => { syncRef.current = null; scrollTarget.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); if (frame !== 0) { cancelAnimationFrame(frame); } }; }, [isControlled, itemIds, offset, getScrollRoot]); // Tear down any in-flight navigation's listeners when the Outline unmounts. useEffect(() => { return () => { navigationRef.current?.teardown(); }; }, []); const setActiveId = (nextActiveId: string) => { if (!isControlled) { setUncontrolledActiveId(nextActiveId); } onActiveIdChange?.(nextActiveId); }; // Defined in render (not memoized) so it always closes over the current props // — it only ever runs from an event handler, never during render. const scrollTo = (id: string): boolean => { const target = typeof document !== 'undefined' ? document.getElementById(id) : null; if (target == null) { return false; } // A second navigation replaces the first: end the old one (balancing its // onNavigateStart) without resuming tracking, since we are about to // suppress it again anyway. navigationRef.current?.supersede(); onNavigateStart?.(id); if (isControlled) { // The consumer owns the active state — notify only. setActiveId(id); } else { // Freeze the indicator during the programmatic scroll instead of moving // it immediately: it lands on the target once the scroll settles, so it // doesn't chase the scroll through intervening sections. suppressRef.current = true; } const scrollRoot = getScrollRoot(); const scrollTarget: HTMLElement | Window = scrollRoot ?? window; let settleTimer = 0; let isSettled = false; const cleanup = () => { scrollTarget.removeEventListener('scrollend', onSettle); scrollTarget.removeEventListener('wheel', onManual); scrollTarget.removeEventListener('touchmove', onManual); window.removeEventListener('keydown', onKeyDown); if (settleTimer !== 0) { clearTimeout(settleTimer); settleTimer = 0; } navigationRef.current = null; }; /** * End the navigation exactly once. `didArrive` is false when the user took * over with a manual scroll mid-flight. `onNavigateEnd` fires either way, * so every `onNavigateStart` is balanced and a consumer's "navigating" * state can never leak. */ const finish = (didArrive: boolean, shouldResume = true) => { if (isSettled) { return; } isSettled = true; cleanup(); if (!isControlled) { suppressRef.current = false; if (didArrive) { setUncontrolledActiveId(id); activeIdRef.current = id; onActiveIdChangeRef.current?.(id); } else if (shouldResume) { // Hand control back to scroll-position tracking. syncRef.current?.(); } } onNavigateEnd?.(id); }; const onSettle = () => finish(true); const onManual = () => finish(false); const onKeyDown = (event: KeyboardEvent) => { // A key the outline itself consumed (arrow roving focus, Space // activation) is prevented, so the browser will not scroll — it is not a // manual scroll intent and must not cancel the navigation it just began. if (!event.defaultPrevented && SCROLL_KEYS.has(event.key)) { finish(false); } }; if (hasScrollOnClick) { // Arm settle detection *before* scrolling so an instant jump (a target // already in position, or prefers-reduced-motion collapsing the smooth // scroll) cannot land before anyone is listening. scrollTarget.addEventListener('scrollend', onSettle, { once: true }); scrollTarget.addEventListener('wheel', onManual, { passive: true }); scrollTarget.addEventListener('touchmove', onManual, { passive: true }); window.addEventListener('keydown', onKeyDown); settleTimer = window.setTimeout(onSettle, SCROLL_SETTLE_TIMEOUT_MS); navigationRef.current = { supersede: () => finish(false, false), teardown: cleanup }; scrollToTarget(target, scrollRoot, scrollTarget, offset); } else { // The consumer owns scrolling — there is no scroll to wait for, so the // navigation is already at rest. Resolving immediately (instead of // waiting on the settle timeout) keeps onNavigateEnd usable for arrival // effects even when hasScrollOnClick is false. finish(true); } return true; }; return { activeId: isControlled ? activeId : uncontrolledActiveId, setActiveId, scrollTo }; }