@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
136 lines (127 loc) • 5.15 kB
JavaScript
'use client';
import * as React from 'react';
/**
* Result returned by `useScrollAnchor`.
*/
/**
* Keeps an anchor element visually fixed in the viewport while a nearby
* container element changes size.
*
* Useful around expand/collapse, accordion, and tab-switch transitions
* where the natural document flow would otherwise push focused content out
* of (or into) the viewport. Uses a `ResizeObserver` on the container to
* react to layout changes without polling, and `scrollBy` to nudge either
* the page or an opt-in scroll container so the anchor's
* `getBoundingClientRect().top` stays constant.
*/
export function useScrollAnchor() {
const containerRef = React.useRef(null);
const scrollContainerRef = React.useRef(null);
// Tracks the cleanup for the currently in-flight anchoring session so a
// new call (or unmount) can abort it cleanly instead of leaving a
// ResizeObserver and listeners holding references to detached nodes.
const activeSessionCleanupRef = React.useRef(null);
React.useEffect(() => {
return () => {
activeSessionCleanupRef.current?.();
activeSessionCleanupRef.current = null;
};
}, []);
const anchorScroll = React.useCallback((anchor, duration) => {
const container = containerRef.current;
if (!container || !anchor) {
return;
}
// Abort any in-flight session before starting a new one; otherwise the
// previous ResizeObserver and listeners would race with this one.
activeSessionCleanupRef.current?.();
activeSessionCleanupRef.current = null;
// Snapshot the scroll target at session start so a later ref change
// doesn't redirect compensation mid-flight. `scrollElement` is the attached
// container (if any); `scrollTarget` is what receives the user-interaction
// listeners (the container or the window).
const scrollElement = scrollContainerRef.current;
const scrollTarget = scrollElement ?? window;
const interactionTarget = scrollTarget;
// Mutable so it can be re-baselined when an attached container can't yet
// absorb a delta (see below).
let initialTop = anchor.getBoundingClientRect().top;
let active = true;
let cleanupTimer;
// ResizeObserver compensates only when the container layout actually
// changes, rather than polling every animation frame. Callbacks fire
// after layout, so getBoundingClientRect() reads already-computed
// values without forcing an extra reflow.
const observer = new ResizeObserver(() => {
if (!active) {
return;
}
const delta = anchor.getBoundingClientRect().top - initialTop;
if (Math.abs(delta) <= 0.5) {
return;
}
if (!scrollElement) {
window.scrollBy(0, delta);
return;
}
const before = scrollElement.scrollTop;
scrollElement.scrollBy(0, delta);
const remainder = delta - (scrollElement.scrollTop - before);
if (Math.abs(remainder) > 0.5) {
// The container couldn't absorb this part — it isn't scrollable yet
// (its content hasn't exceeded its `max-height`). Re-baseline instead
// of forcing the difference elsewhere: scrolling the page would shift
// the surrounding layout, and carrying the delta forward would snap the
// anchor back the instant the container becomes scrollable. Accepting
// the small drift now keeps the surrounding layout still and lets the
// container hold the anchor smoothly from here on.
initialTop += remainder;
}
});
function cleanup() {
if (!active) {
return;
}
active = false;
clearTimeout(cleanupTimer);
observer.disconnect();
interactionTarget.removeEventListener('wheel', stopOnUserInteraction);
interactionTarget.removeEventListener('touchmove', stopOnUserInteraction);
interactionTarget.removeEventListener('pointerdown', stopOnUserInteraction);
interactionTarget.removeEventListener('keydown', stopOnUserInteraction);
if (activeSessionCleanupRef.current === cleanup) {
activeSessionCleanupRef.current = null;
}
}
activeSessionCleanupRef.current = cleanup;
// Stop compensating if the user interacts (scroll, click, keyboard),
// since UI changes like tab switches can invalidate anchor measurements.
function stopOnUserInteraction() {
cleanup();
}
interactionTarget.addEventListener('wheel', stopOnUserInteraction, {
passive: true,
once: true
});
interactionTarget.addEventListener('touchmove', stopOnUserInteraction, {
passive: true,
once: true
});
interactionTarget.addEventListener('pointerdown', stopOnUserInteraction, {
passive: true,
once: true
});
interactionTarget.addEventListener('keydown', stopOnUserInteraction, {
passive: true,
once: true
});
observer.observe(container);
// Safety cleanup after the layout transition completes.
cleanupTimer = setTimeout(cleanup, duration + 500);
}, []);
return {
containerRef,
scrollContainerRef,
anchorScroll
};
}