blob: b5a84541e1d610c5c12ade1d205360060e9d0f0f [file]
// Focus follows the level, for any settings page that owns more than one.
//
// Without this a level change leaves the ring on `document.body` — the control
// that had focus just unmounted — and a keyboard user restarts from the top of
// the document on every move.
//
// Only the mechanism is shared, because only the mechanism drifts invisibly:
// skipping the first render, the animation frame, `preventScroll`, cancelling
// on the way out. Which element each level focuses stays with the page — a
// wrong target puts the ring somewhere you can see.
import { useEffect, useRef } from 'react';
export function useSettingsRouteFocus(options: {
/** The level being rendered. A change here is what moves focus. */
level: string;
/**
* Anything that should re-run the move without changing `level`. Compared by
* identity, and only needed by a page whose level is not the whole route.
*/
routeKey?: unknown;
/** False while the page has nothing to focus yet (loading its data). */
isReady?: boolean;
/** Where focus goes for the level being rendered; null to leave it alone. */
resolveTarget(level: string): HTMLElement | null;
}): void {
const { level, routeKey, isReady = true } = options;
// `resolveTarget` closes over fresh state every render but must not re-run
// the effect, so it is read through a ref written in its own effect —
// committed values only, which a ref written during render would not be.
const resolveTargetRef = useRef(options.resolveTarget);
useEffect(() => {
resolveTargetRef.current = options.resolveTarget;
});
// Navigating, not arriving: the page does not grab focus when the settings
// surface first renders it — the user is still on the settings nav item they
// clicked to get here, and taking the ring off it strands them.
const hasNavigatedRef = useRef(false);
useEffect(() => {
if (!isReady) return;
if (!hasNavigatedRef.current) {
hasNavigatedRef.current = true;
return;
}
const frame = window.requestAnimationFrame(() => {
// `preventScroll` because this is a landing, not a jump: the level just
// rendered at the top of the content area, and scrolling to whatever the
// target happens to be would push its own header out of view.
resolveTargetRef.current(level)?.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frame);
}, [level, routeKey, isReady]);
}