@base-ui-components/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.
344 lines (335 loc) • 12.8 kB
JavaScript
'use client';
import * as React from 'react';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs';
import { useOnMount } from '@base-ui-components/utils/useOnMount';
import { AnimationFrame, useAnimationFrame } from '@base-ui-components/utils/useAnimationFrame';
import { warn } from '@base-ui-components/utils/warn';
import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js";
import { REASONS } from "../../utils/reasons.js";
import { CollapsiblePanelDataAttributes } from "./CollapsiblePanelDataAttributes.js";
import { AccordionRootDataAttributes } from "../../accordion/root/AccordionRootDataAttributes.js";
export function useCollapsiblePanel(parameters) {
const {
abortControllerRef,
animationTypeRef,
externalRef,
height,
hiddenUntilFound,
keepMounted,
id: idParam,
mounted,
onOpenChange,
open,
panelRef,
runOnceAnimationsFinish,
setDimensions,
setMounted,
setOpen,
setVisible,
transitionDimensionRef,
visible,
width
} = parameters;
const isBeforeMatchRef = React.useRef(false);
const latestAnimationNameRef = React.useRef(null);
const shouldCancelInitialOpenAnimationRef = React.useRef(open);
const shouldCancelInitialOpenTransitionRef = React.useRef(open);
const endingStyleFrame = useAnimationFrame();
/**
* When opening, the `hidden` attribute is removed immediately.
* When closing, the `hidden` attribute is set after any exit animations runs.
*/
const hidden = React.useMemo(() => {
if (animationTypeRef.current === 'css-animation') {
return !visible;
}
return !open && !mounted;
}, [open, mounted, visible, animationTypeRef]);
/**
* When `keepMounted` is `true` this runs once as soon as it exists in the DOM
* regardless of initial open state.
*
* When `keepMounted` is `false` this runs on every mount, typically every
* time it opens. If the panel is in the middle of a close transition that is
* interrupted and re-opens, this won't run as the panel was not unmounted.
*/
const handlePanelRef = useStableCallback(element => {
if (!element) {
return undefined;
}
if (animationTypeRef.current == null || transitionDimensionRef.current == null) {
const panelStyles = getComputedStyle(element);
const hasAnimation = panelStyles.animationName !== 'none' && panelStyles.animationName !== '';
const hasTransition = panelStyles.transitionDuration !== '0s' && panelStyles.transitionDuration !== '';
/**
* animationTypeRef is safe to read in render because it's only ever set
* once here during the first render and never again.
* https://react.dev/learn/referencing-values-with-refs#best-practices-for-refs
*/
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.');
}
} else if (panelStyles.animationName === 'none' && panelStyles.transitionDuration !== '0s') {
animationTypeRef.current = 'css-transition';
} else if (panelStyles.animationName !== 'none' && panelStyles.transitionDuration === '0s') {
animationTypeRef.current = 'css-animation';
} else {
animationTypeRef.current = 'none';
}
/**
* We need to know in advance which side is being collapsed when using CSS
* transitions in order to set the value of width/height to `0px` momentarily.
* Setting both to `0px` will break layout.
*/
if (element.getAttribute(AccordionRootDataAttributes.orientation) === 'horizontal' || panelStyles.transitionProperty.indexOf('width') > -1) {
transitionDimensionRef.current = 'width';
} else {
transitionDimensionRef.current = 'height';
}
}
if (animationTypeRef.current !== 'css-transition') {
return undefined;
}
if (height === undefined || width === undefined) {
setDimensions({
height: element.scrollHeight,
width: element.scrollWidth
});
if (shouldCancelInitialOpenTransitionRef.current) {
element.style.setProperty('transition-duration', '0s');
}
}
let frame = -1;
let nextFrame = -1;
frame = AnimationFrame.request(() => {
shouldCancelInitialOpenTransitionRef.current = false;
nextFrame = AnimationFrame.request(() => {
/**
* This is slightly faster than another RAF and is the earliest
* opportunity to remove the temporary `transition-duration: 0s` that
* was applied to cancel opening transitions of initially open panels.
* https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/
*/
setTimeout(() => {
element.style.removeProperty('transition-duration');
});
});
});
return () => {
AnimationFrame.cancel(frame);
AnimationFrame.cancel(nextFrame);
};
});
const mergedPanelRef = useMergedRefs(externalRef, panelRef, handlePanelRef);
useIsoLayoutEffect(() => {
if (animationTypeRef.current !== 'css-transition') {
return undefined;
}
const panel = panelRef.current;
if (!panel) {
return undefined;
}
let resizeFrame = -1;
if (abortControllerRef.current != null) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
if (open) {
const originalLayoutStyles = {
'justify-content': panel.style.justifyContent,
'align-items': panel.style.alignItems,
'align-content': panel.style.alignContent,
'justify-items': panel.style.justifyItems
};
/* opening */
Object.keys(originalLayoutStyles).forEach(key => {
panel.style.setProperty(key, 'initial', 'important');
});
/**
* When `keepMounted={false}` and the panel is initially closed, the very
* first time it opens (not any subsequent opens) `data-starting-style` is
* off or missing by a frame so we need to set it manually. Otherwise any
* CSS properties expected to transition using [data-starting-style] may
* be mis-timed and appear to be complete skipped.
*/
if (!shouldCancelInitialOpenTransitionRef.current && !keepMounted) {
panel.setAttribute(CollapsiblePanelDataAttributes.startingStyle, '');
}
setDimensions({
height: panel.scrollHeight,
width: panel.scrollWidth
});
resizeFrame = AnimationFrame.request(() => {
Object.entries(originalLayoutStyles).forEach(([key, value]) => {
if (value === '') {
panel.style.removeProperty(key);
} else {
panel.style.setProperty(key, value);
}
});
});
} else {
if (panel.scrollHeight === 0 && panel.scrollWidth === 0) {
return undefined;
}
/* closing */
setDimensions({
height: panel.scrollHeight,
width: panel.scrollWidth
});
const abortController = new AbortController();
abortControllerRef.current = abortController;
const signal = abortController.signal;
let attributeObserver = null;
const endingStyleAttribute = CollapsiblePanelDataAttributes.endingStyle;
// Wait for `[data-ending-style]` to be applied.
attributeObserver = new MutationObserver(mutationList => {
const hasEndingStyle = mutationList.some(mutation => mutation.type === 'attributes' && mutation.attributeName === endingStyleAttribute);
if (hasEndingStyle) {
attributeObserver?.disconnect();
attributeObserver = null;
runOnceAnimationsFinish(() => {
setDimensions({
height: 0,
width: 0
});
panel.style.removeProperty('content-visibility');
setMounted(false);
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null;
}
}, signal);
}
});
attributeObserver.observe(panel, {
attributes: true,
attributeFilter: [endingStyleAttribute]
});
return () => {
attributeObserver?.disconnect();
endingStyleFrame.cancel();
if (abortControllerRef.current === abortController) {
abortController.abort();
abortControllerRef.current = null;
}
};
}
return () => {
AnimationFrame.cancel(resizeFrame);
};
}, [abortControllerRef, animationTypeRef, endingStyleFrame, hiddenUntilFound, keepMounted, mounted, open, panelRef, runOnceAnimationsFinish, setDimensions, setMounted]);
useIsoLayoutEffect(() => {
if (animationTypeRef.current !== 'css-animation') {
return;
}
const panel = panelRef.current;
if (!panel) {
return;
}
latestAnimationNameRef.current = panel.style.animationName || latestAnimationNameRef.current;
panel.style.setProperty('animation-name', 'none');
setDimensions({
height: panel.scrollHeight,
width: panel.scrollWidth
});
if (!shouldCancelInitialOpenAnimationRef.current && !isBeforeMatchRef.current) {
panel.style.removeProperty('animation-name');
}
if (open) {
if (abortControllerRef.current != null) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setMounted(true);
setVisible(true);
} else {
abortControllerRef.current = new AbortController();
runOnceAnimationsFinish(() => {
setMounted(false);
setVisible(false);
abortControllerRef.current = null;
}, abortControllerRef.current.signal);
}
}, [abortControllerRef, animationTypeRef, open, panelRef, runOnceAnimationsFinish, setDimensions, setMounted, setVisible, visible]);
useOnMount(() => {
const frame = AnimationFrame.request(() => {
shouldCancelInitialOpenAnimationRef.current = false;
});
return () => AnimationFrame.cancel(frame);
});
useIsoLayoutEffect(() => {
if (!hiddenUntilFound) {
return undefined;
}
const panel = panelRef.current;
if (!panel) {
return undefined;
}
let frame = -1;
let nextFrame = -1;
if (open && isBeforeMatchRef.current) {
panel.style.transitionDuration = '0s';
setDimensions({
height: panel.scrollHeight,
width: panel.scrollWidth
});
frame = AnimationFrame.request(() => {
isBeforeMatchRef.current = false;
nextFrame = AnimationFrame.request(() => {
setTimeout(() => {
panel.style.removeProperty('transition-duration');
});
});
});
}
return () => {
AnimationFrame.cancel(frame);
AnimationFrame.cancel(nextFrame);
};
}, [hiddenUntilFound, open, panelRef, setDimensions]);
useIsoLayoutEffect(() => {
const panel = panelRef.current;
if (panel && hiddenUntilFound && hidden) {
/**
* 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');
/**
* Set data-starting-style here to persist the closed styles, this is to
* prevent transitions from starting when the `hidden` attribute changes
* to `'until-found'` as they could have different `display` properties:
* https://github.com/tailwindlabs/tailwindcss/pull/14625
*/
if (animationTypeRef.current === 'css-transition') {
panel.setAttribute(CollapsiblePanelDataAttributes.startingStyle, '');
}
}
}, [hiddenUntilFound, hidden, animationTypeRef, panelRef]);
React.useEffect(function registerBeforeMatchListener() {
const panel = panelRef.current;
if (!panel) {
return undefined;
}
function handleBeforeMatch(event) {
isBeforeMatchRef.current = true;
setOpen(true);
onOpenChange(true, createChangeEventDetails(REASONS.none, event));
}
panel.addEventListener('beforematch', handleBeforeMatch);
return () => {
panel.removeEventListener('beforematch', handleBeforeMatch);
};
}, [onOpenChange, panelRef, setOpen]);
return React.useMemo(() => ({
props: {
hidden,
id: idParam,
ref: mergedPanelRef
}
}), [hidden, idParam, mergedPanelRef]);
}