@primer/react
Version:
An implementation of GitHub's Primer Design System using React
140 lines (139 loc) • 6.6 kB
JavaScript
import { useProvidedRefOrCreate } from "./useProvidedRefOrCreate.js";
import useIsomorphicLayoutEffect from "../utils/useIsomorphicLayoutEffect.js";
import { useResizeObserver } from "./useResizeObserver.js";
import React from "react";
import { getAnchoredPosition } from "@primer/behaviors";
//#region src/hooks/useAnchoredPosition.ts
/**
* 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(void 0);
const [_, setPrevHeight] = React.useState(void 0);
const topPositionChanged = (prevPosition, newPosition) => {
return prevPosition && ["outside-top", "inside-top"].includes(prevPosition.anchorSide) && (prevPosition.anchorSide !== newPosition.anchorSide || prevPosition.top < newPosition.top);
};
const updateElementHeight = () => {
let heightUpdated = false;
setPrevHeight((prevHeight) => {
var _floatingElementRef$c, _floatingElementRef$c2;
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;
if (((_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) > ((_floatingElementRef$c3 = (_floatingElementRef$c4 = floatingElementRef.current) === null || _floatingElementRef$c4 === void 0 ? void 0 : _floatingElementRef$c4.clientHeight) !== null && _floatingElementRef$c3 !== void 0 ? _floatingElementRef$c3 : 0) && updateElementHeight()) return prev;
}
if (prev && prev.anchorSide === newPosition.anchorSide) {
var _savedOnPositionChang;
(_savedOnPositionChang = savedOnPositionChange.current) === null || _savedOnPositionChang === void 0 || _savedOnPositionChang.call(savedOnPositionChange, newPosition);
}
return newPosition;
});
} else {
var _savedOnPositionChang2;
setPosition(void 0);
(_savedOnPositionChang2 = savedOnPositionChange.current) === null || _savedOnPositionChang2 === void 0 || _savedOnPositionChang2.call(savedOnPositionChange, void 0);
}
setPrevHeight((_floatingElementRef$c5 = floatingElementRef.current) === null || _floatingElementRef$c5 === void 0 ? void 0 : _floatingElementRef$c5.clientHeight);
}, [
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]);
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, void 0, [], enabled);
useResizeObserver(updatePosition, floatingElementRef, [], enabled);
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) 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
};
}
//#endregion
export { useAnchoredPosition };