@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.
350 lines (341 loc) • 13.6 kB
JavaScript
;
'use client';
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.useCollapsiblePanel = useCollapsiblePanel;
var React = _interopRequireWildcard(require("react"));
var _useIsoLayoutEffect = require("@base-ui-components/utils/useIsoLayoutEffect");
var _useStableCallback = require("@base-ui-components/utils/useStableCallback");
var _useMergedRefs = require("@base-ui-components/utils/useMergedRefs");
var _useOnMount = require("@base-ui-components/utils/useOnMount");
var _useAnimationFrame = require("@base-ui-components/utils/useAnimationFrame");
var _warn = require("@base-ui-components/utils/warn");
var _createBaseUIEventDetails = require("../../utils/createBaseUIEventDetails");
var _reasons = require("../../utils/reasons");
var _CollapsiblePanelDataAttributes = require("./CollapsiblePanelDataAttributes");
var _AccordionRootDataAttributes = require("../../accordion/root/AccordionRootDataAttributes");
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 = (0, _useAnimationFrame.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 = (0, _useStableCallback.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') {
(0, _warn.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.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 = _useAnimationFrame.AnimationFrame.request(() => {
shouldCancelInitialOpenTransitionRef.current = false;
nextFrame = _useAnimationFrame.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 () => {
_useAnimationFrame.AnimationFrame.cancel(frame);
_useAnimationFrame.AnimationFrame.cancel(nextFrame);
};
});
const mergedPanelRef = (0, _useMergedRefs.useMergedRefs)(externalRef, panelRef, handlePanelRef);
(0, _useIsoLayoutEffect.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.CollapsiblePanelDataAttributes.startingStyle, '');
}
setDimensions({
height: panel.scrollHeight,
width: panel.scrollWidth
});
resizeFrame = _useAnimationFrame.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.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 () => {
_useAnimationFrame.AnimationFrame.cancel(resizeFrame);
};
}, [abortControllerRef, animationTypeRef, endingStyleFrame, hiddenUntilFound, keepMounted, mounted, open, panelRef, runOnceAnimationsFinish, setDimensions, setMounted]);
(0, _useIsoLayoutEffect.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]);
(0, _useOnMount.useOnMount)(() => {
const frame = _useAnimationFrame.AnimationFrame.request(() => {
shouldCancelInitialOpenAnimationRef.current = false;
});
return () => _useAnimationFrame.AnimationFrame.cancel(frame);
});
(0, _useIsoLayoutEffect.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 = _useAnimationFrame.AnimationFrame.request(() => {
isBeforeMatchRef.current = false;
nextFrame = _useAnimationFrame.AnimationFrame.request(() => {
setTimeout(() => {
panel.style.removeProperty('transition-duration');
});
});
});
}
return () => {
_useAnimationFrame.AnimationFrame.cancel(frame);
_useAnimationFrame.AnimationFrame.cancel(nextFrame);
};
}, [hiddenUntilFound, open, panelRef, setDimensions]);
(0, _useIsoLayoutEffect.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.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, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.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]);
}