@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.
382 lines (371 loc) • 16.1 kB
JavaScript
'use client';
import * as React from 'react';
import { addEventListener } from '@base-ui/utils/addEventListener';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useMergedRefs } from '@base-ui/utils/useMergedRefs';
import { AnimationFrame } from '@base-ui/utils/useAnimationFrame';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useValueAsRef } from '@base-ui/utils/useValueAsRef';
import { warn } from '@base-ui/utils/warn';
import { ownerWindow } from '@base-ui/utils/owner';
import { createChangeEventDetails } from "../../internals/createBaseUIEventDetails.js";
import { REASONS } from "../../internals/reasons.js";
import { useOpenChangeComplete } from "../../internals/useOpenChangeComplete.js";
import { useAnimationsFinished } from "../../internals/useAnimationsFinished.js";
import { CollapsiblePanelDataAttributes } from "./CollapsiblePanelDataAttributes.js";
const EMPTY_DIMENSIONS = {
height: undefined,
width: undefined
};
export function useCollapsiblePanel(parameters) {
const {
externalRef,
hiddenUntilFound,
id: idParam,
keepMounted,
mounted,
onOpenChange,
open,
setMounted,
setOpen,
transitionStatus
} = parameters;
const panelRef = React.useRef(null);
const animationTypeRef = React.useRef(null);
const [dimensions, setDimensionsUnwrapped] = React.useState(EMPTY_DIMENSIONS);
const lastMeasuredDimensionsRef = React.useRef(EMPTY_DIMENSIONS);
// `beforematch` should reveal the matched content immediately, so the next
// open cycle skips author-defined motion once and then returns to normal.
const shouldSkipNextOpenRef = React.useRef(false);
// Keyframe mount animations on initially open panels cause a visible layout
// shift during the server-rendered first paint, so suppress that first open
// lifecycle until the panel has been closed once.
const shouldPreventMountAnimationRef = React.useRef(open);
// React.Activity tears down Effects while preserving state, so revealing an
// already-open panel would otherwise replay its CSS keyframe open animation.
const shouldPreventActivityResumeAnimationRef = React.useRef(false);
// Some open paths intentionally bypass motion, but the shared root transition
// status still advances asynchronously. Override the panel to idle so its data
// attributes and dimension cleanup reflect the immediate open state.
const [forcePanelIdle, setForcePanelIdle] = React.useState(false);
const pendingTemporaryStyleRestoreRef = React.useRef(null);
const mergedPanelRef = useMergedRefs(externalRef, panelRef);
const latestStateRef = useValueAsRef({
mounted,
open
});
// Only used to handle panel close
const runOnceCloseAnimationsFinish = useAnimationsFinished(panelRef, false, false);
const hidden = !open && !mounted;
const panelTransitionStatus = forcePanelIdle ? 'idle' : transitionStatus;
const shouldPreventOpenAnimation = open && (
// These 2 refs are safe to read in render, they are only written from committed
// layout/effect paths and gate one-shot motion suppression for the next open
// lifecycle. They intentionally expose the last committed motion snapshot.
shouldPreventMountAnimationRef.current || shouldPreventActivityResumeAnimationRef.current);
const renderedDimensions = !open && mounted &&
// These 2 refs are also safe to read in render, both hold the last committed
// animation mode and measurement. This fallback only restores a previously
// measured pixel size after the live dimensions state has been reset back to `auto`.
animationTypeRef.current === 'css-animation' && dimensions.height === undefined && dimensions.width === undefined ? lastMeasuredDimensionsRef.current : dimensions;
const shouldPersistHiddenTransitionStyles = hiddenUntilFound && hidden && animationTypeRef.current !== 'css-animation';
// Most measured dimensions are reused later when CSS keyframe closes need a
// pixel size after the rendered dimensions have been reset back to `auto`.
// Passing `false` is only for clearing the current dimensions state.
const setDimensions = useStableCallback((nextDimensions, shouldCacheMeasurement = true) => {
if (shouldCacheMeasurement) {
lastMeasuredDimensionsRef.current = nextDimensions;
}
setDimensionsUnwrapped(nextDimensions);
});
const restorePendingTemporaryStyle = useStableCallback(() => {
pendingTemporaryStyleRestoreRef.current?.();
pendingTemporaryStyleRestoreRef.current = null;
});
const setPendingTemporaryStyleRestore = useStableCallback(restore => {
restorePendingTemporaryStyle();
pendingTemporaryStyleRestoreRef.current = () => {
pendingTemporaryStyleRestoreRef.current = null;
restore();
};
});
// React.Activity unmounts Effects while preserving component state. If that
// teardown happens while an already-open keyframe panel is visible, remember
// to suppress the replayed open animation on the next committed reveal.
const markActivityResumeAnimationSuppressed = useStableCallback(() => {
if (open && mounted && animationTypeRef.current === 'css-animation') {
shouldPreventActivityResumeAnimationRef.current = true;
}
});
useIsoLayoutEffect(() => {
// `forcePanelIdle` is only a temporary override for open paths that skip
// motion. Keep it active while the shared root still reports `starting`,
// then drop it once the root transition state catches up.
if (!forcePanelIdle || transitionStatus === 'starting') {
return;
}
setForcePanelIdle(false);
}, [forcePanelIdle, transitionStatus]);
React.useEffect(() => {
return () => {
markActivityResumeAnimationSuppressed();
restorePendingTemporaryStyle();
};
}, [markActivityResumeAnimationSuppressed, restorePendingTemporaryStyle]);
useIsoLayoutEffect(() => {
const panel = panelRef.current;
if (!panel) {
return undefined;
}
// `beforematch` can temporarily force a `0s` motion duration so the matched
// content reveals immediately. Restore the authored duration before detecting
// the next close animation type, otherwise that first close is misread as
// "no motion" and the close transition or keyframe gets skipped.
if (!open && pendingTemporaryStyleRestoreRef.current) {
restorePendingTemporaryStyle();
}
const animationType = getAnimationType(panel, shouldPreventOpenAnimation);
animationTypeRef.current = animationType;
// Initially open keyframe panels skip their first paint animation to avoid
// layout shift, but we still need to cache the expanded size so the first
// close animation can start from pixels instead of `auto`.
if (open && transitionStatus === 'idle' && shouldPreventMountAnimationRef.current && animationType === 'css-animation') {
lastMeasuredDimensionsRef.current = getDimensions(panel);
return undefined;
}
// Handle the opening pass: measure the expanded size and, when necessary,
// neutralize author-defined motion so the panel can open immediately.
if (open && transitionStatus === 'starting') {
// `beforematch` opens should reveal the panel immediately so find-in-page
// does not wait for the author-defined transition or animation to finish.
const skipNextOpen = shouldSkipNextOpenRef.current;
shouldSkipNextOpenRef.current = false;
if (animationType === 'none') {
setDimensions(getDimensions(panel));
setForcePanelIdle(true);
return undefined;
}
if (animationType === 'css-transition') {
const restoreLayoutStyles = resetLayoutStyles(panel);
setDimensions(getDimensions(panel));
if (!skipNextOpen) {
return restoreLayoutStyles;
}
const restoreTransitionDuration = setTemporaryStyle(panel, 'transition-duration', '0s');
setPendingTemporaryStyleRestore(restoreTransitionDuration);
setForcePanelIdle(true);
return restoreLayoutStyles;
}
if (animationType === 'css-animation') {
setDimensions(getDimensions(panel));
if (!skipNextOpen) {
const restoreAnimationName = setTemporaryStyle(panel, 'animation-name', 'none');
restoreAnimationName();
return undefined;
}
const restoreAnimationName = setTemporaryStyle(panel, 'animation-name', 'none');
const restoreAnimationDuration = setTemporaryStyle(panel, 'animation-duration', '0s');
restoreAnimationName();
setPendingTemporaryStyleRestore(restoreAnimationDuration);
setForcePanelIdle(true);
return undefined;
}
}
// Capture the current size as soon as close is requested, before the
// deferred ending phase applies closed styles. This keeps close transitions
// starting from a measured pixel value, including interrupted opens.
if (!open && mounted && (transitionStatus === 'idle' || transitionStatus === 'starting')) {
if (animationType === 'none') {
setDimensions(EMPTY_DIMENSIONS, false);
setMounted(false);
return undefined;
}
if (animationType === 'css-animation') {
shouldPreventMountAnimationRef.current = false;
shouldPreventActivityResumeAnimationRef.current = false;
}
setDimensions(getDimensions(panel));
return undefined;
}
if (transitionStatus !== 'ending') {
return undefined;
}
if (animationType === 'none') {
setMounted(false);
return undefined;
}
const nextDimensions = getDimensions(panel);
const hasMeasuredSize = (nextDimensions.height ?? 0) > 0 || (nextDimensions.width ?? 0) > 0;
if (!hasMeasuredSize) {
setMounted(false);
return undefined;
}
setDimensions(nextDimensions);
if (animationType === 'css-animation') {
const restoreAnimationName = setTemporaryStyle(panel, 'animation-name', 'none');
restoreAnimationName();
}
return undefined;
}, [mounted, open, restorePendingTemporaryStyle, setDimensions, setMounted, setPendingTemporaryStyleRestore, shouldPreventOpenAnimation, transitionStatus]);
useOpenChangeComplete({
enabled: open && mounted && panelTransitionStatus === 'idle',
open: true,
ref: panelRef,
onComplete() {
if (!open) {
return;
}
setDimensions(EMPTY_DIMENSIONS, false);
}
});
// Closing panels need extra sequencing beyond `useOpenChangeComplete`.
// This passive effect runs after the `ending` render has committed, so
// `[data-ending-style]` is already present. Chrome can still register the
// exit transition one frame later when an Accordion closes one item while
// opening another, so wait one frame before watching animations.
// See https://github.com/mui/base-ui/issues/3099
React.useEffect(() => {
if (open || !mounted || panelTransitionStatus !== 'ending') {
return undefined;
}
const panel = panelRef.current;
if (!panel) {
return undefined;
}
const abortController = new AbortController();
let endingStyleFrame = -1;
function handleComplete() {
if (latestStateRef.current.open) {
return;
}
setMounted(false);
setDimensions(EMPTY_DIMENSIONS, false);
}
endingStyleFrame = AnimationFrame.request(() => {
if (!abortController.signal.aborted) {
runOnceCloseAnimationsFinish(handleComplete, abortController.signal);
}
});
return () => {
AnimationFrame.cancel(endingStyleFrame);
abortController.abort();
};
}, [latestStateRef, mounted, open, panelTransitionStatus, runOnceCloseAnimationsFinish, setDimensions, setMounted]);
useIsoLayoutEffect(() => {
const panel = panelRef.current;
if (!panel || !hiddenUntilFound || !hidden) {
return;
}
// React only supports a boolean for the `hidden` attribute and forces
// legit string values to booleans so we have to force it back in the DOM
// when necessary: https://github.com/facebook/react/issues/24740
panel.setAttribute('hidden', 'until-found');
}, [hidden, hiddenUntilFound]);
React.useEffect(function registerBeforeMatchListener() {
const panel = panelRef.current;
if (!panel) {
return undefined;
}
function handleBeforeMatch(event) {
shouldSkipNextOpenRef.current = true;
setOpen(true);
onOpenChange(true, createChangeEventDetails(REASONS.none, event));
}
return addEventListener(panel, 'beforematch', handleBeforeMatch);
}, [onOpenChange, setOpen]);
const shouldRender = keepMounted || hiddenUntilFound || mounted || open;
return {
height: renderedDimensions.height,
props: {
...(shouldPersistHiddenTransitionStyles ? {
[CollapsiblePanelDataAttributes.startingStyle]: ''
} : undefined),
hidden,
id: idParam
},
ref: mergedPanelRef,
shouldPreventOpenAnimation,
shouldRender,
transitionStatus: panelTransitionStatus,
width: renderedDimensions.width
};
}
function getDimensions(element) {
return {
height: element.scrollHeight,
width: element.scrollWidth
};
}
function getAnimationType(element, hasSuppressedMountAnimation = false) {
const panelStyles = ownerWindow(element).getComputedStyle(element);
const hasAnimation = (panelStyles.animationName.split(',').map(name => name.trim()).some(name => name !== '' && name !== 'none') || hasSuppressedMountAnimation) && hasNonZeroDuration(panelStyles.animationDuration);
const hasTransition = hasNonZeroDuration(panelStyles.transitionDuration);
if (hasAnimation && hasTransition) {
if (process.env.NODE_ENV !== 'production') {
warn('CSS transitions and CSS animations both detected on Collapsible or Accordion panel.', 'Only one of either animation type should be used.');
}
return 'css-transition';
}
if (hasTransition) {
return 'css-transition';
}
if (hasAnimation) {
return 'css-animation';
}
return 'none';
}
function hasNonZeroDuration(value) {
return value.split(',').map(part => part.trim()).some(part => part !== '' && Number.parseFloat(part) > 0);
}
/**
* Temporarily overrides an inline style property and returns a cleanup that
* restores the previous inline value and priority.
* @param element - The element whose inline style should be updated.
* @param property - The CSS property name to override.
* @param value - The temporary value to assign.
* @returns A cleanup function that restores the original inline style state.
*/
function setTemporaryStyle(element, property, value) {
const previousValue = element.style.getPropertyValue(property);
const previousPriority = element.style.getPropertyPriority(property);
element.style.setProperty(property, value);
return () => {
if (previousValue === '') {
element.style.removeProperty(property);
return;
}
element.style.setProperty(property, previousValue, previousPriority);
};
}
/**
* Temporarily resets inline alignment styles that can distort scroll-based
* size measurements, then restores them on the next animation frame.
* @param element - The panel element being measured.
* @returns A cleanup function that cancels the scheduled restore and reapplies
* the original inline layout styles immediately.
*/
function resetLayoutStyles(element) {
const originalLayoutStyles = {
'justify-content': element.style.justifyContent,
'align-items': element.style.alignItems,
'align-content': element.style.alignContent,
'justify-items': element.style.justifyItems
};
Object.keys(originalLayoutStyles).forEach(key => {
element.style.setProperty(key, 'initial', 'important');
});
function restoreLayoutStyles() {
Object.entries(originalLayoutStyles).forEach(([key, value]) => {
if (value === '') {
element.style.removeProperty(key);
return;
}
element.style.setProperty(key, value);
});
}
const frame = AnimationFrame.request(restoreLayoutStyles);
return () => {
AnimationFrame.cancel(frame);
restoreLayoutStyles();
};
}