diff --git a/docs/src/app/(private)/experiments/collapsible/_icons.tsx b/docs/src/app/(private)/experiments/collapsible/_icons.tsx new file mode 100644 index 0000000000..e85233ea4b --- /dev/null +++ b/docs/src/app/(private)/experiments/collapsible/_icons.tsx @@ -0,0 +1,21 @@ +'use client'; +import * as React from 'react'; + +export default function Nothing() { + return
This is just a dummy file to hold icons
; +} + +export function ExpandMoreIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/docs/src/app/(private)/experiments/collapsible/animation-old.tsx b/docs/src/app/(private)/experiments/collapsible/animation-old.tsx new file mode 100644 index 0000000000..e79c53a790 --- /dev/null +++ b/docs/src/app/(private)/experiments/collapsible/animation-old.tsx @@ -0,0 +1,40 @@ +'use client'; +import * as React from 'react'; +import { Collapsible } from '@base-ui-components/react/collapsible'; +import styles from './animation.module.css'; + +export default function CollapsibleCssAnimation() { + return ( +
+ + Trigger + +
+

+ He rubbed his eyes, and came close to the picture, and examined it + again. There were no signs of any change when he looked into the actual + painting, and yet there was no doubt that the whole expression had + altered. It was not a mere fancy of his own. The thing was horribly + apparent. +

+
+
+
+ + + Trigger + +
+

+ He rubbed his eyes, and came close to the picture, and examined it + again. There were no signs of any change when he looked into the actual + painting, and yet there was no doubt that the whole expression had + altered. It was not a mere fancy of his own. The thing was horribly + apparent. +

+
+
+
+
+ ); +} diff --git a/docs/src/app/(private)/experiments/collapsible/animation.module.css b/docs/src/app/(private)/experiments/collapsible/animation.module.css new file mode 100644 index 0000000000..e719704c6d --- /dev/null +++ b/docs/src/app/(private)/experiments/collapsible/animation.module.css @@ -0,0 +1,96 @@ +@keyframes slide-down { + from { + height: 0; + } + + to { + height: var(--collapsible-panel-height); + } +} + +@keyframes slide-up { + from { + height: var(--collapsible-panel-height); + } + + to { + height: 0; + } +} + +.Panel { + overflow: hidden; + box-sizing: border-box; + width: 100%; + + &[data-open] { + animation: slide-down var(--duration) ease-out; + } + + &[data-closed] { + animation: slide-up var(--duration) ease-in; + } +} + +/* the styles below are irrelevant to the features */ +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 5rem; +} + +.wrapper { + font-family: system-ui, sans-serif; + line-height: 1.4; + display: flex; + flex-flow: column nowrap; + align-items: stretch; + gap: 1rem; + align-self: flex-start; +} + +.Root { + --width: 320px; + --duration: 900ms; + + width: var(--width); + + & + .Root { + margin-top: 2rem; + } +} + +.Trigger { + display: flex; + width: 100%; + align-items: center; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + background-color: var(--color-gray-200); + color: var(--color-gray-900); + + & svg { + transform: rotate(-90deg); + transition: transform var(--duration) ease-in; + } + + &[data-panel-open] svg { + transform: rotate(0); + transition: transform var(--duration) ease-out; + } +} + +.Content { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.25rem; + padding: 0.5rem; + border-radius: 0.25rem; + background-color: var(--color-gray-200); + cursor: text; + + & p { + overflow-wrap: break-word; + } +} diff --git a/docs/src/app/(private)/experiments/collapsible/animation.tsx b/docs/src/app/(private)/experiments/collapsible/animation.tsx new file mode 100644 index 0000000000..8f7a4eab29 --- /dev/null +++ b/docs/src/app/(private)/experiments/collapsible/animation.tsx @@ -0,0 +1,154 @@ +'use client'; +import * as React from 'react'; +import { + useEnhancedEffect, + // useTransitionStatus, +} from '@base-ui-components/react/utils'; +import classes from './animation.module.css'; + +import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished'; +import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback'; +import { useOnMount } from '../../../../../../packages/react/src/utils/useOnMount'; +import { ExpandMoreIcon } from './_icons'; + +function Collapsible(props: { + defaultOpen?: boolean; + keepMounted?: boolean; + id?: string; +}) { + const { keepMounted = true, defaultOpen = false, id } = props; + + const [open, setOpen] = React.useState(defaultOpen); + + const [visible, setVisible] = React.useState(open); + + const [mounted, setMounted] = React.useState(open); + + const [height, setHeight] = React.useState(0); + + const latestAnimationNameRef = React.useRef(null); + const shouldCancelInitialOpenAnimationRef = React.useRef(open); + + const isHidden = !visible; + + const panelRef: React.RefObject = React.useRef(null); + + const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false); + + const handleTrigger = useEventCallback(() => { + const nextOpen = !open; + + const panel = panelRef.current; + + if (panel) { + panel.style.removeProperty('animation-name'); + } + + if (!keepMounted) { + if (!visible && nextOpen) { + setVisible(true); + } + if (!mounted && nextOpen) { + setMounted(true); + } + } + setOpen(nextOpen); + }); + + useEnhancedEffect(() => { + const panel = panelRef.current; + if (!panel) { + return; + } + + latestAnimationNameRef.current = + panel.style.animationName || latestAnimationNameRef.current; + + panel.style.animationName = 'none'; + + setHeight(panel.scrollHeight); + + if (!shouldCancelInitialOpenAnimationRef.current) { + panel.style.removeProperty('animation-name'); + } + + if (open) { + setMounted(true); + } + + runOnceAnimationsFinish(() => { + setVisible(open); + }); + }, [open, visible, runOnceAnimationsFinish]); + + useOnMount(() => { + const frame = requestAnimationFrame(() => { + shouldCancelInitialOpenAnimationRef.current = false; + }); + return () => cancelAnimationFrame(frame); + }); + + return ( +
+ + + {(keepMounted || (!keepMounted && mounted)) && ( + + )} +
+ ); +} + +export default function App() { + return ( +
+
+
keepMounted: true
+ + + + + ——— +
+
+
keepMounted: false
+ + + + ——— +
+
+ ); +} diff --git a/docs/src/app/(private)/experiments/collapsible/new.tsx b/docs/src/app/(private)/experiments/collapsible/new.tsx new file mode 100644 index 0000000000..b1ff6e47e4 --- /dev/null +++ b/docs/src/app/(private)/experiments/collapsible/new.tsx @@ -0,0 +1,465 @@ +'use client'; +import * as React from 'react'; +import { + useEnhancedEffect, + useTransitionStatus, +} from '@base-ui-components/react/utils'; +import classes from './animation.module.css'; +import { ExpandMoreIcon } from './_icons'; + +import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished'; +import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback'; +import { useForkRef } from '../../../../../../packages/react/src/utils/useForkRef'; +import { useOnMount } from '../../../../../../packages/react/src/utils/useOnMount'; +import { warn } from '../../../../../../packages/react/src/utils/warn'; + +const STARTING_HOOK = { 'data-starting-style': '' }; +const ENDING_HOOK = { 'data-ending-style': '' }; + +type AnimationType = 'css-transition' | 'css-animation' | 'none' | null; + +function Collapsible(props: { + defaultOpen?: boolean; + keepMounted?: boolean; + id?: string; + hiddenUntilFound?: boolean; +}) { + const { + keepMounted = true, + defaultOpen = false, + id, + hiddenUntilFound: hiddenUntilFoundProp = true, + } = props; + + const [open, setOpen] = React.useState(defaultOpen); + + const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); + + const [visible, setVisible] = React.useState(open); + + const styleHooks = React.useMemo(() => { + if (transitionStatus === 'starting') { + return STARTING_HOOK; + } + if (transitionStatus === 'ending') { + return ENDING_HOOK; + } + return null; + }, [transitionStatus]); + + const [height, setHeight] = React.useState(undefined); + + const shouldCancelInitialOpenTransitionRef = React.useRef(open); + const latestAnimationNameRef = React.useRef(null); + const shouldCancelInitialOpenAnimationRef = React.useRef(open); + + const isBeforeMatchRef = React.useRef(false); + + const animationTypeRef = React.useRef(null); + + const isHidden = React.useMemo(() => { + if (animationTypeRef.current === 'css-animation') { + return !visible; + } + + if (keepMounted) { + return !open; + } + + return !open && !mounted; + }, [keepMounted, open, mounted, visible]); + + const panelRef: React.RefObject = React.useRef(null); + + /** + * 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 = useEventCallback((element: HTMLElement) => { + if (!element) { + return; + } + /** + * This ref 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 (animationTypeRef.current == null) { + const panelStyles = getComputedStyle(element); + if ( + panelStyles.animationName !== 'none' && + panelStyles.transitionDuration !== '0s' + ) { + warn('CSS transitions and CSS animations both detected'); + } 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'; + } + } + + if (animationTypeRef.current !== 'css-transition') { + return; + } + + /** + * Explicitly set `display` to ensure the panel is actually rendered before + * measuring anything. `!important` is to needed to override a conflicting + * Tailwind v4 default that sets `display: none !important` on `[hidden]`: + * https://github.com/tailwindlabs/tailwindcss/blob/cd154a4f471e7a63cc27cad15dada650de89d52b/packages/tailwindcss/preflight.css#L320-L326 + */ + element.style.setProperty('display', 'block', 'important'); // TODO: maybe this can be set more conditionally + + if (height === undefined) { + /** + * 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) { + element.setAttribute('data-starting-style', ''); + } + + setHeight(element.scrollHeight); + element.style.removeProperty('display'); + + if (shouldCancelInitialOpenTransitionRef.current) { + element.style.setProperty('transition-duration', '0s'); + } + } + + requestAnimationFrame(() => { + shouldCancelInitialOpenTransitionRef.current = false; + requestAnimationFrame(() => { + /** + * 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'); + }); + }); + }); + }); + + const mergedRef = useForkRef(panelRef, handlePanelRef); + + const abortControllerRef = React.useRef(null); + + const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false); + + const handleTrigger = useEventCallback(() => { + const nextOpen = !open; + + const panel = panelRef.current; + + if (animationTypeRef.current === 'css-animation' && panel != null) { + panel.style.removeProperty('animation-name'); + } + + if (!hiddenUntilFoundProp && !keepMounted) { + if (animationTypeRef.current === 'css-transition') { + if (!mounted && nextOpen) { + setMounted(true); + } + } + + if (animationTypeRef.current === 'css-animation') { + if (!visible && nextOpen) { + setVisible(true); + } + if (!mounted && nextOpen) { + setMounted(true); + } + } + } + setOpen(nextOpen); + + /** + * When `keepMounted={false}` and when opening, the element isn't inserted + * in the DOM at this point so bail out here and resume in an effect. + */ + if (!panel || animationTypeRef.current !== 'css-transition') { + return; + } + + panel.style.setProperty('display', 'block', 'important'); + + if (nextOpen) { + /* opening */ + if (abortControllerRef.current != null) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + panel.style.removeProperty('display'); + panel.style.removeProperty('content-visibility'); + panel.style.setProperty('height', '0px'); + + requestAnimationFrame(() => { + panel.style.removeProperty('height'); + setHeight(panel.scrollHeight); + }); + } else { + if (hiddenUntilFoundProp) { + panel.style.setProperty('content-visibility', 'visible'); + } + /* closing */ + requestAnimationFrame(() => { + setHeight(0); + }); + + abortControllerRef.current = new AbortController(); + + runOnceAnimationsFinish(() => { + panel.style.removeProperty('display'); + panel.style.removeProperty('content-visibility'); + abortControllerRef.current = null; + }, abortControllerRef.current.signal); + } + }); + + /** + * This only handles CSS transitions when `keepMounted={false}` as we may not + * have access to the panel element in the DOM in the trigger event handler. + */ + useEnhancedEffect(() => { + if (animationTypeRef.current !== 'css-transition' || keepMounted) { + return; + } + + const panel = panelRef.current; + + if (!panel) { + return; + } + + if (open) { + if (abortControllerRef.current != null) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + /* opening */ + panel.style.height = '0px'; + + requestAnimationFrame(() => { + /** + * When `keepMounted={false}` this is the earliest opportunity to unset + * the temporary `display` property that was set in `handlePanelRef` + */ + panel.style.removeProperty('display'); + + panel.style.removeProperty('height'); + setHeight(panel.scrollHeight); + }); + } else { + /* closing */ + requestAnimationFrame(() => { + setHeight(0); + }); + + abortControllerRef.current = new AbortController(); + + runOnceAnimationsFinish(() => { + panel.style.removeProperty('content-visibility'); + setMounted(false); + abortControllerRef.current = null; + }, abortControllerRef.current.signal); + } + }, [ + keepMounted, + open, + mounted, + setMounted, + runOnceAnimationsFinish, + hiddenUntilFoundProp, + ]); + + useEnhancedEffect(() => { + if (!hiddenUntilFoundProp) { + return; + } + + const panel = panelRef.current; + if (!panel) { + return; + } + + if (open && isBeforeMatchRef.current) { + panel.style.transitionDuration = '0s'; + setHeight(panel.scrollHeight); + requestAnimationFrame(() => { + isBeforeMatchRef.current = false; + requestAnimationFrame(() => { + setTimeout(() => { + panel.style.removeProperty('transition-duration'); + }); + }); + }); + } + }, [hiddenUntilFoundProp, open]); + + useEnhancedEffect(() => { + 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'); + + setHeight(panel.scrollHeight); + + if (!shouldCancelInitialOpenAnimationRef.current && !isBeforeMatchRef.current) { + panel.style.removeProperty('animation-name'); + } + + if (open) { + setMounted(true); + setVisible(true); + } else { + runOnceAnimationsFinish(() => { + setMounted(false); + setVisible(false); + }); + } + }, [open, visible, runOnceAnimationsFinish, setMounted]); + + useOnMount(() => { + const frame = requestAnimationFrame(() => { + shouldCancelInitialOpenAnimationRef.current = false; + }); + return () => cancelAnimationFrame(frame); + }); + + useEnhancedEffect(() => { + const panel = panelRef.current; + + if (panel && hiddenUntilFoundProp && isHidden) { + /** + * 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('data-starting-style', ''); + } + } + }, [hiddenUntilFoundProp, isHidden]); + + React.useEffect( + function registerBeforeMatchListener() { + const panel = panelRef.current; + if (!panel) { + return undefined; + } + + function handleBeforeMatch() { + isBeforeMatchRef.current = true; + setOpen(true); + } + + panel.addEventListener('beforematch', handleBeforeMatch); + + return () => { + panel.removeEventListener('beforematch', handleBeforeMatch); + }; + }, + [setOpen], + ); + + return ( +
+ + + {(keepMounted || hiddenUntilFoundProp || (!keepMounted && mounted)) && ( + + )} +
+ ); +} + +export default function App() { + return ( +
+
+
keepMounted: true
+ + + + + ——— +
+
+
keepMounted: false
+ + + + ——— +
+
+ ); +} diff --git a/docs/src/app/(private)/experiments/collapsible/transition.module.css b/docs/src/app/(private)/experiments/collapsible/transition.module.css new file mode 100644 index 0000000000..1cc18c1f44 --- /dev/null +++ b/docs/src/app/(private)/experiments/collapsible/transition.module.css @@ -0,0 +1,77 @@ +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 5rem; +} + +.wrapper { + font-family: system-ui, sans-serif; + line-height: 1.4; + display: flex; + flex-flow: column nowrap; + align-items: stretch; + gap: 1rem; + align-self: flex-start; +} + +.Root { + --width: 320px; + --duration: 1000ms; + + width: var(--width); + + & + .Root { + margin-top: 2rem; + } +} + +.Trigger { + display: flex; + width: 100%; + align-items: center; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + background-color: var(--color-gray-200); + color: var(--color-gray-900); + + & svg { + transform: rotate(-90deg); + transition: transform var(--duration) ease-in; + } + + &[data-panel-open] svg { + transform: rotate(0); + transition: transform var(--duration) ease-out; + } +} + +.Panel { + overflow: hidden; + box-sizing: border-box; + width: 100%; + + height: var(--collapsible-panel-height); + + transition: all var(--duration) ease-out; + + &[data-starting-style], + &[data-ending-style] { + height: 0; + opacity: 0; + } +} + +.Content { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.25rem; + padding: 0.5rem; + border-radius: 0.25rem; + background-color: var(--color-gray-200); + cursor: text; + + & p { + overflow-wrap: break-word; + } +} diff --git a/docs/src/app/(private)/experiments/collapsible/transition.tsx b/docs/src/app/(private)/experiments/collapsible/transition.tsx new file mode 100644 index 0000000000..cae940d135 --- /dev/null +++ b/docs/src/app/(private)/experiments/collapsible/transition.tsx @@ -0,0 +1,301 @@ +'use client'; +import * as React from 'react'; +import { + useEnhancedEffect, + useTransitionStatus, +} from '@base-ui-components/react/utils'; +import classes from './transition.module.css'; + +import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished'; +import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback'; +import { useForkRef } from '../../../../../../packages/react/src/utils/useForkRef'; +import { warn } from '../../../../../../packages/react/src/utils/warn'; +import { ExpandMoreIcon } from './_icons'; + +const STARTING_HOOK = { 'data-starting-style': '' }; +const ENDING_HOOK = { 'data-ending-style': '' }; + +type AnimationType = 'css-transition' | 'css-animation' | 'none' | null; + +function Collapsible(props: { + defaultOpen?: boolean; + keepMounted?: boolean; + id?: string; +}) { + const { keepMounted = true, defaultOpen = false, id } = props; + + const [open, setOpen] = React.useState(defaultOpen); + + const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); + + const styleHooks = React.useMemo(() => { + if (transitionStatus === 'starting') { + return STARTING_HOOK; + } + if (transitionStatus === 'ending') { + return ENDING_HOOK; + } + return null; + }, [transitionStatus]); + + const [height, setHeight] = React.useState(undefined); + + const shouldCancelInitialOpenTransitionRef = React.useRef(open); + + const isHidden = React.useMemo(() => { + if (keepMounted) { + return !open; + } + + return !open && !mounted; + }, [keepMounted, open, mounted]); + + const animationTypeRef = React.useRef(null); + const panelRef: React.RefObject = React.useRef(null); + /** + * 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 = useEventCallback((element: HTMLElement) => { + if (!element) { + return; + } + if (animationTypeRef.current == null) { + const panelStyles = getComputedStyle(element); + if ( + panelStyles.animationName !== 'none' && + panelStyles.transitionDelay !== '0s' + ) { + warn('CSS transitions and CSS animations both detected'); + } 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'; + } + } + // console.log('animationType', animationTypeRef.current); + + /** + * Explicitly set `display` to ensure the panel is actually rendered before + * measuring anything. `!important` is to needed to override a conflicting + * Tailwind v4 default that sets `display: none !important` on `[hidden]`: + * https://github.com/tailwindlabs/tailwindcss/blob/cd154a4f471e7a63cc27cad15dada650de89d52b/packages/tailwindcss/preflight.css#L320-L326 + */ + element.style.setProperty('display', 'block', 'important'); // TODO: maybe this can be set more conditionally + + if (height === undefined) { + /** + * 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) { + element.setAttribute('data-starting-style', ''); + } + + setHeight(element.scrollHeight); + element.style.removeProperty('display'); + + if (shouldCancelInitialOpenTransitionRef.current) { + element.style.transitionDuration = '0s'; + } + } + + requestAnimationFrame(() => { + shouldCancelInitialOpenTransitionRef.current = false; + requestAnimationFrame(() => { + /** + * 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'); + }); + }); + }); + }); + + const mergedRef = useForkRef(panelRef, handlePanelRef); + + const abortControllerRef = React.useRef(null); + + const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false); + + const handleTrigger = useEventCallback(() => { + const nextOpen = !open; + + if (!keepMounted) { + if (!mounted && nextOpen) { + setMounted(true); + } + } + setOpen(nextOpen); + + const panel = panelRef.current; + if (!panel) { + return; + } + + panel.style.setProperty('display', 'block', 'important'); + + if (nextOpen) { + if (abortControllerRef.current != null) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + panel.style.removeProperty('display'); + + /* opening */ + panel.style.height = '0px'; + + requestAnimationFrame(() => { + panel.style.removeProperty('height'); + setHeight(panel.scrollHeight); + }); + } else { + /* closing */ + requestAnimationFrame(() => { + setHeight(0); + }); + + abortControllerRef.current = new AbortController(); + + runOnceAnimationsFinish(() => { + // TODO: !important may be needed + panel.style.setProperty('display', 'none'); + abortControllerRef.current = null; + }, abortControllerRef.current.signal); + } + }); + + /** + * This only handles `keepMounted={false}` as the state changes can't be done + * in the event handler + */ + useEnhancedEffect(() => { + if (keepMounted) { + return; + } + + const panel = panelRef.current; + if (!panel) { + return; + } + + if (open) { + if (abortControllerRef.current != null) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + /* opening */ + panel.style.height = '0px'; + + requestAnimationFrame(() => { + /** + * When `keepMounted={false}` this is the earliest opportunity to unset + * the temporary `display` property that was set in `handlePanelRef` + */ + panel.style.removeProperty('display'); + + panel.style.removeProperty('height'); + + setHeight(panel.scrollHeight); + }); + } else { + /* closing */ + requestAnimationFrame(() => { + setHeight(0); + }); + + abortControllerRef.current = new AbortController(); + + runOnceAnimationsFinish(() => { + setMounted(false); + abortControllerRef.current = null; + }, abortControllerRef.current.signal); + } + }, [keepMounted, open, mounted, setMounted, runOnceAnimationsFinish]); + + return ( +
+ + + {(keepMounted || (!keepMounted && mounted)) && ( + + )} +
+ ); +} + +export default function App() { + return ( +
+
+
keepMounted: true
+ + + + + ——— +
+
+
keepMounted: false
+ + + + ——— +
+
+ ); +} diff --git a/packages/react/src/collapsible/panel/useCollapsiblePanel.ts b/packages/react/src/collapsible/panel/useCollapsiblePanel.ts index ee1072fa95..24954fc0c2 100644 --- a/packages/react/src/collapsible/panel/useCollapsiblePanel.ts +++ b/packages/react/src/collapsible/panel/useCollapsiblePanel.ts @@ -283,7 +283,7 @@ export function useCollapsiblePanel( }, [hiddenUntilFound, isOpen]); const hidden = hiddenUntilFound ? 'until-found' : 'hidden'; - + console.log('isOpen', isOpen); const getRootProps: useCollapsiblePanel.ReturnValue['getRootProps'] = React.useCallback( (externalProps = {}) => mergeProps<'button'>( diff --git a/packages/react/src/collapsible/root/useCollapsibleRoot.ts b/packages/react/src/collapsible/root/useCollapsibleRoot.ts index 6fe3a73ad7..91e55ef24c 100644 --- a/packages/react/src/collapsible/root/useCollapsibleRoot.ts +++ b/packages/react/src/collapsible/root/useCollapsibleRoot.ts @@ -17,7 +17,7 @@ export function useCollapsibleRoot( state: 'open', }); - const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, true); + const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); const [panelId, setPanelId] = React.useState(useBaseUiId()); diff --git a/packages/react/src/utils/useAnimationsFinished.ts b/packages/react/src/utils/useAnimationsFinished.ts index b76224a76c..81d74a9ab2 100644 --- a/packages/react/src/utils/useAnimationsFinished.ts +++ b/packages/react/src/utils/useAnimationsFinished.ts @@ -23,38 +23,54 @@ export function useAnimationsFinished( React.useEffect(() => cancelTasks, [cancelTasks]); - return useEventCallback((fnToExecute: () => void) => { - cancelTasks(); - - const element = ref.current; - - if (!element) { - return; - } - - if (typeof element.getAnimations !== 'function' || globalThis.BASE_UI_ANIMATIONS_DISABLED) { - fnToExecute(); - } else { - frameRef.current = requestAnimationFrame(() => { - function exec() { - if (!element) { - return; + return useEventCallback( + ( + /** + * A function to execute once all animations have finished. + */ + fnToExecute: () => void, + /** + * An optional [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that + * can be used to abort `fnToExecute` before all the animations have finished. + * @default null + */ + signal: AbortSignal | null = null, + ) => { + cancelTasks(); + + const element = ref.current; + + if (!element) { + return; + } + + if (typeof element.getAnimations !== 'function' || globalThis.BASE_UI_ANIMATIONS_DISABLED) { + fnToExecute(); + } else { + frameRef.current = requestAnimationFrame(() => { + function exec() { + if (!element) { + return; + } + + Promise.allSettled(element.getAnimations().map((anim) => anim.finished)).then(() => { + if (signal != null && signal.aborted) { + return; + } + // Synchronously flush the unmounting of the component so that the browser doesn't + // paint: https://github.com/mui/base-ui/issues/979 + ReactDOM.flushSync(fnToExecute); + }); } - Promise.allSettled(element.getAnimations().map((anim) => anim.finished)).then(() => { - // Synchronously flush the unmounting of the component so that the browser doesn't - // paint: https://github.com/mui/base-ui/issues/979 - ReactDOM.flushSync(fnToExecute); - }); - } - - // `open: true` animations need to wait for the next tick to be detected - if (waitForNextTick) { - timeoutRef.current = window.setTimeout(exec); - } else { - exec(); - } - }); - } - }); + // `open: true` animations need to wait for the next tick to be detected + if (waitForNextTick) { + timeoutRef.current = window.setTimeout(exec); + } else { + exec(); + } + }); + } + }, + ); } diff --git a/packages/react/src/utils/useTransitionStatus.ts b/packages/react/src/utils/useTransitionStatus.ts index d33ea23c5d..539338fa73 100644 --- a/packages/react/src/utils/useTransitionStatus.ts +++ b/packages/react/src/utils/useTransitionStatus.ts @@ -2,25 +2,20 @@ import * as React from 'react'; import { useEnhancedEffect } from './useEnhancedEffect'; -export type TransitionStatus = 'starting' | 'ending' | undefined; +export type TransitionStatus = 'starting' | 'ending' | 'idle' | undefined; /** * Provides a status string for CSS animations. * @param open - a boolean that determines if the element is open. - * @param delayStartingStatus - a boolean that set the `starting` status one - * tick later. Example use-case: collapsible needs an extra frame in order - * to measure the panel contents. * @ignore - internal hook. */ -export function useTransitionStatus(open: boolean, delayStartingStatus = false) { +export function useTransitionStatus(open: boolean) { const [transitionStatus, setTransitionStatus] = React.useState(); const [mounted, setMounted] = React.useState(open); if (open && !mounted) { setMounted(true); - if (transitionStatus !== 'starting' && !delayStartingStatus) { - setTransitionStatus('starting'); - } + setTransitionStatus('starting'); } if (!open && mounted && transitionStatus !== 'ending') { @@ -35,19 +30,18 @@ export function useTransitionStatus(open: boolean, delayStartingStatus = false) if (!open) { return undefined; } - - if (delayStartingStatus) { + if (open && mounted && transitionStatus !== 'idle') { setTransitionStatus('starting'); } const frame = requestAnimationFrame(() => { - setTransitionStatus(undefined); + setTransitionStatus('idle'); }); return () => { cancelAnimationFrame(frame); }; - }, [open, delayStartingStatus]); + }, [open, mounted, setTransitionStatus, transitionStatus]); return React.useMemo( () => ({