UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

156 lines (151 loc) 8.16 kB
import React from 'react'; import { getAnchoredPosition } from '@primer/behaviors'; import { useProvidedRefOrCreate } from './useProvidedRefOrCreate.js'; import { useResizeObserver } from './useResizeObserver.js'; import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect.js'; /** * Returns all scrollable ancestor elements of the given element, plus the window. * An element is scrollable if its computed overflow/overflow-x/overflow-y is * 'auto', 'scroll', or 'overlay'. */ function getScrollableAncestors(element) { const scrollables = []; let current = element.parentElement; while (current) { const style = getComputedStyle(current); const overflowY = style.overflowY; const overflowX = style.overflowX; if (/auto|scroll|overlay/.test(overflowY) || /auto|scroll|overlay/.test(overflowX)) { scrollables.push(current); } current = current.parentElement; } scrollables.push(window); return scrollables; } /** * Calculates the top and left values for an absolutely-positioned floating element * to be anchored to some anchor element. Returns refs for the floating element * and the anchor element, along with the position. * @param settings Settings for calculating the anchored position. * @param dependencies Dependencies to determine when to re-calculate the position. * @returns An object of {top: number, left: number} to absolutely-position the * floating element. */ function useAnchoredPosition(settings, dependencies = []) { var _settings$enabled; const floatingElementRef = useProvidedRefOrCreate(settings === null || settings === void 0 ? void 0 : settings.floatingElementRef); const anchorElementRef = useProvidedRefOrCreate(settings === null || settings === void 0 ? void 0 : settings.anchorElementRef); const enabled = (_settings$enabled = settings === null || settings === void 0 ? void 0 : settings.enabled) !== null && _settings$enabled !== void 0 ? _settings$enabled : true; const savedOnPositionChange = React.useRef(settings === null || settings === void 0 ? void 0 : settings.onPositionChange); const [position, setPosition] = React.useState(undefined); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, setPrevHeight] = React.useState(undefined); const topPositionChanged = (prevPosition, newPosition) => { return prevPosition && ['outside-top', 'inside-top'].includes(prevPosition.anchorSide) && ( // either the anchor changed or the element is trying to shrink in height prevPosition.anchorSide !== newPosition.anchorSide || prevPosition.top < newPosition.top); }; const updateElementHeight = () => { let heightUpdated = false; setPrevHeight(prevHeight => { var _floatingElementRef$c, _floatingElementRef$c2; // if the element is trying to shrink in height, restore to old height to prevent it from jumping if (prevHeight && prevHeight > ((_floatingElementRef$c = (_floatingElementRef$c2 = floatingElementRef.current) === null || _floatingElementRef$c2 === void 0 ? void 0 : _floatingElementRef$c2.clientHeight) !== null && _floatingElementRef$c !== void 0 ? _floatingElementRef$c : 0)) { requestAnimationFrame(() => { floatingElementRef.current.style.height = `${prevHeight}px`; }); heightUpdated = true; } return prevHeight; }); return heightUpdated; }; const updatePosition = React.useCallback(() => { var _floatingElementRef$c5; if (!enabled) return; if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { const newPosition = getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings); setPosition(prev => { if (settings !== null && settings !== void 0 && settings.pinPosition && topPositionChanged(prev, newPosition)) { var _anchorElementRef$cur, _anchorElementRef$cur2, _floatingElementRef$c3, _floatingElementRef$c4; const anchorTop = (_anchorElementRef$cur = (_anchorElementRef$cur2 = anchorElementRef.current) === null || _anchorElementRef$cur2 === void 0 ? void 0 : _anchorElementRef$cur2.getBoundingClientRect().top) !== null && _anchorElementRef$cur !== void 0 ? _anchorElementRef$cur : 0; const elementStillFitsOnTop = anchorTop > ((_floatingElementRef$c3 = (_floatingElementRef$c4 = floatingElementRef.current) === null || _floatingElementRef$c4 === void 0 ? void 0 : _floatingElementRef$c4.clientHeight) !== null && _floatingElementRef$c3 !== void 0 ? _floatingElementRef$c3 : 0); if (elementStillFitsOnTop && updateElementHeight()) { return prev; } } if (prev && prev.anchorSide === newPosition.anchorSide) { var _savedOnPositionChang; // if the position hasn't changed, don't update (_savedOnPositionChang = savedOnPositionChange.current) === null || _savedOnPositionChang === void 0 ? void 0 : _savedOnPositionChang.call(savedOnPositionChange, newPosition); } return newPosition; }); } else { var _savedOnPositionChang2; setPosition(undefined); (_savedOnPositionChang2 = savedOnPositionChange.current) === null || _savedOnPositionChang2 === void 0 ? void 0 : _savedOnPositionChang2.call(savedOnPositionChange, undefined); } setPrevHeight((_floatingElementRef$c5 = floatingElementRef.current) === null || _floatingElementRef$c5 === void 0 ? void 0 : _floatingElementRef$c5.clientHeight); }, // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo [floatingElementRef, anchorElementRef, enabled, ...dependencies]); useIsomorphicLayoutEffect(() => { savedOnPositionChange.current = settings === null || settings === void 0 ? void 0 : settings.onPositionChange; }, [settings === null || settings === void 0 ? void 0 : settings.onPositionChange]); // Defer the first updatePosition to useEffect when the overlay is closed on // mount, avoiding paint-blocking cascading setState. If the overlay is already // open on mount, run synchronously in useLayoutEffect to prevent a flash. // After mount (including Suspense reappear), only call updatePosition when // both refs are attached — skipping closed overlays avoids unnecessary setState. const hasMountedRef = React.useRef(false); useIsomorphicLayoutEffect(() => { if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { hasMountedRef.current = true; updatePosition(); } }, [updatePosition, floatingElementRef, anchorElementRef]); React.useEffect(() => { if (!hasMountedRef.current) { hasMountedRef.current = true; updatePosition(); } }, [updatePosition]); useResizeObserver(updatePosition, undefined, [], enabled); // watches for changes in window size useResizeObserver(updatePosition, floatingElementRef, [], enabled); // watches for changes in floating element size // Recalculate position when any scrollable ancestor of the anchor scrolls. // Uses requestAnimationFrame to avoid layout thrashing during scroll. React.useEffect(() => { if (!enabled) return; const anchorEl = anchorElementRef.current; if (!anchorEl) return; let rafId = null; const handleScroll = () => { if (rafId !== null) return; rafId = requestAnimationFrame(() => { rafId = null; updatePosition(); }); }; const scrollables = getScrollableAncestors(anchorEl); for (const scrollable of scrollables) { // eslint-disable-next-line github/prefer-observers -- IntersectionObserver cannot detect continuous scroll position changes needed for repositioning scrollable.addEventListener('scroll', handleScroll); } return () => { for (const scrollable of scrollables) { scrollable.removeEventListener('scroll', handleScroll); } if (rafId !== null) { cancelAnimationFrame(rafId); } }; }, [anchorElementRef, updatePosition, enabled]); return { floatingElementRef, anchorElementRef, position }; } export { useAnchoredPosition };