@base-ui/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
182 lines (176 loc) • 7.6 kB
JavaScript
'use client';
import * as React from 'react';
import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { NOOP } from '@base-ui/utils/empty';
import { useAnimationsFinished } from "./useAnimationsFinished.js";
import { getCssDimensions } from "./getCssDimensions.js";
import { EMPTY_OBJECT } from "./constants.js";
const supportsResizeObserver = typeof ResizeObserver !== 'undefined';
const DEFAULT_ENABLED = () => true;
/**
* Allows the element to automatically resize based on its content while supporting animations.
*/
export function usePopupAutoResize(parameters) {
const {
popupElement,
positionerElement,
content,
mounted,
enabled = DEFAULT_ENABLED,
onMeasureLayout: onMeasureLayoutParam,
onMeasureLayoutComplete: onMeasureLayoutCompleteParam,
side,
direction
} = parameters;
const runOnceAnimationsFinish = useAnimationsFinished(popupElement, true, false);
const animationFrame = useAnimationFrame();
const committedDimensionsRef = React.useRef(null);
const liveDimensionsRef = React.useRef(null);
const isInitialRenderRef = React.useRef(true);
const restoreAnchoringStylesRef = React.useRef(NOOP);
const onMeasureLayout = useStableCallback(onMeasureLayoutParam);
const onMeasureLayoutComplete = useStableCallback(onMeasureLayoutCompleteParam);
const anchoringStyles = React.useMemo(() => {
// Ensure popup size transitions correctly when anchored to `bottom` (side=top) or `right` (side=left).
let isOriginSide = side === 'top';
let isPhysicalLeft = side === 'left';
if (direction === 'rtl') {
isOriginSide = isOriginSide || side === 'inline-end';
isPhysicalLeft = isPhysicalLeft || side === 'inline-end';
} else {
isOriginSide = isOriginSide || side === 'inline-start';
isPhysicalLeft = isPhysicalLeft || side === 'inline-start';
}
return isOriginSide ? {
position: 'absolute',
[side === 'top' ? 'bottom' : 'top']: '0',
[isPhysicalLeft ? 'right' : 'left']: '0'
} : EMPTY_OBJECT;
}, [side, direction]);
useIsoLayoutEffect(() => {
// Reset the state when the popup is closed.
if (!mounted || !enabled() || !supportsResizeObserver) {
restoreAnchoringStylesRef.current = NOOP;
isInitialRenderRef.current = true;
committedDimensionsRef.current = null;
liveDimensionsRef.current = null;
return undefined;
}
if (!popupElement || !positionerElement) {
return undefined;
}
restoreAnchoringStylesRef.current = applyElementStyles(popupElement, anchoringStyles);
const observer = new ResizeObserver(entries => {
const entry = entries[0];
if (entry) {
liveDimensionsRef.current = {
width: Math.ceil(entry.borderBoxSize[0].inlineSize),
height: Math.ceil(entry.borderBoxSize[0].blockSize)
};
}
});
observer.observe(popupElement);
// Measure the rendered size to enable transitions:
setPopupCssSize(popupElement, 'auto');
const restorePopupPosition = overrideElementStyle(popupElement, 'position', 'static');
const restorePopupTransform = overrideElementStyle(popupElement, 'transform', 'none');
const restorePopupScale = overrideElementStyle(popupElement, 'scale', '1');
const restorePositionerAvailableSize = applyElementStyles(positionerElement, {
'--available-width': 'max-content',
'--available-height': 'max-content'
});
function restoreMeasurementOverrides() {
restorePopupPosition();
restorePopupTransform();
restorePositionerAvailableSize();
}
function restoreMeasurementOverridesIncludingScale() {
restoreMeasurementOverrides();
restorePopupScale();
}
onMeasureLayout?.();
// Initial render (for each time the popup opens).
if (isInitialRenderRef.current || committedDimensionsRef.current === null) {
setPositionerCssSize(positionerElement, 'max-content');
const dimensions = getCssDimensions(popupElement);
committedDimensionsRef.current = dimensions;
setPositionerCssSize(positionerElement, dimensions);
restoreMeasurementOverridesIncludingScale();
onMeasureLayoutComplete?.(null, dimensions);
isInitialRenderRef.current = false;
return () => {
observer.disconnect();
restoreAnchoringStylesRef.current();
restoreAnchoringStylesRef.current = NOOP;
};
}
// Subsequent renders while open (when `content` changes).
setPopupCssSize(popupElement, 'auto');
setPositionerCssSize(positionerElement, 'max-content');
const previousDimensions = committedDimensionsRef.current ?? liveDimensionsRef.current;
const newDimensions = getCssDimensions(popupElement);
// Commit immediately so future content changes have a stable previous size, even if
// ResizeObserver runs after this point.
committedDimensionsRef.current = newDimensions;
if (!previousDimensions) {
setPositionerCssSize(positionerElement, newDimensions);
restoreMeasurementOverridesIncludingScale();
onMeasureLayoutComplete?.(null, newDimensions);
return () => {
observer.disconnect();
animationFrame.cancel();
restoreAnchoringStylesRef.current();
restoreAnchoringStylesRef.current = NOOP;
};
}
setPopupCssSize(popupElement, previousDimensions);
restoreMeasurementOverridesIncludingScale();
onMeasureLayoutComplete?.(previousDimensions, newDimensions);
setPositionerCssSize(positionerElement, newDimensions);
const abortController = new AbortController();
animationFrame.request(() => {
setPopupCssSize(popupElement, newDimensions);
runOnceAnimationsFinish(() => {
popupElement.style.setProperty('--popup-width', 'auto');
popupElement.style.setProperty('--popup-height', 'auto');
}, abortController.signal);
});
return () => {
observer.disconnect();
abortController.abort();
animationFrame.cancel();
restoreAnchoringStylesRef.current();
restoreAnchoringStylesRef.current = NOOP;
};
}, [content, popupElement, positionerElement, runOnceAnimationsFinish, animationFrame, enabled, mounted, onMeasureLayout, onMeasureLayoutComplete, anchoringStyles]);
}
function overrideElementStyle(element, property, value) {
const originalValue = element.style.getPropertyValue(property);
element.style.setProperty(property, value);
return () => {
element.style.setProperty(property, originalValue);
};
}
function applyElementStyles(element, styles) {
const restorers = [];
for (const [key, value] of Object.entries(styles)) {
restorers.push(overrideElementStyle(element, key, value));
}
return restorers.length ? () => {
restorers.forEach(restore => restore());
} : NOOP;
}
function setPopupCssSize(popupElement, size) {
const width = size === 'auto' ? 'auto' : `${size.width}px`;
const height = size === 'auto' ? 'auto' : `${size.height}px`;
popupElement.style.setProperty('--popup-width', width);
popupElement.style.setProperty('--popup-height', height);
}
function setPositionerCssSize(positionerElement, size) {
const width = size === 'max-content' ? 'max-content' : `${size.width}px`;
const height = size === 'max-content' ? 'max-content' : `${size.height}px`;
positionerElement.style.setProperty('--positioner-width', width);
positionerElement.style.setProperty('--positioner-height', height);
}