From 5b232a3513066dd8e375a402638f30dad15f2425 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 22 Jul 2024 14:10:25 +0800 Subject: [PATCH] CSS animation support --- .../collapsible/CssAnimatedCollapsible.js | 149 ++++++++++++++++++ .../collapsible/CssAnimatedCollapsible.tsx | 149 ++++++++++++++++++ .../UnstyledCollapsibleIntroduction.js | 76 +++++++++ .../UnstyledCollapsibleIntroduction.tsx | 76 +++++++++ .../components/collapsible/collapsible.md | 72 +++++++++ docs/pages/experiments/collapsible.tsx | 39 ++++- .../Content/CollapsibleContent.test.tsx | 4 + .../Content/CollapsibleContent.tsx | 16 +- .../Content/CollapsibleContent.types.ts | 5 + .../Content/useCollapsibleContent.ts | 114 +++++++++++++- .../Collapsible/Root/CollapsibleRoot.test.tsx | 29 +++- .../src/Collapsible/Root/CollapsibleRoot.tsx | 6 +- .../Collapsible/Root/CollapsibleRoot.types.ts | 12 +- .../src/Collapsible/Root/styleHooks.ts | 9 ++ .../Collapsible/Root/useCollapsibleRoot.ts | 38 +++-- .../Trigger/CollapsibleTrigger.test.tsx | 4 + 16 files changed, 759 insertions(+), 39 deletions(-) create mode 100644 docs/data/base/components/collapsible/CssAnimatedCollapsible.js create mode 100644 docs/data/base/components/collapsible/CssAnimatedCollapsible.tsx create mode 100644 docs/data/base/components/collapsible/UnstyledCollapsibleIntroduction.js create mode 100644 docs/data/base/components/collapsible/UnstyledCollapsibleIntroduction.tsx diff --git a/docs/data/base/components/collapsible/CssAnimatedCollapsible.js b/docs/data/base/components/collapsible/CssAnimatedCollapsible.js new file mode 100644 index 0000000000..5594395eb9 --- /dev/null +++ b/docs/data/base/components/collapsible/CssAnimatedCollapsible.js @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { useTheme } from '@mui/system'; +import * as Collapsible from '@base_ui/react/Collapsible'; + +export default function CssAnimatedCollapsible() { + return ( +
+ + + + + + + + Trigger + + +

This is the collapsed content

+

This is the second paragraph

+

This is a longer sentence and also the third paragraph

+
+
+ +
+ ); +} + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +export function Styles() { + const isDarkMode = useIsDarkMode(); + return ( + + ); +} diff --git a/docs/data/base/components/collapsible/CssAnimatedCollapsible.tsx b/docs/data/base/components/collapsible/CssAnimatedCollapsible.tsx new file mode 100644 index 0000000000..5594395eb9 --- /dev/null +++ b/docs/data/base/components/collapsible/CssAnimatedCollapsible.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { useTheme } from '@mui/system'; +import * as Collapsible from '@base_ui/react/Collapsible'; + +export default function CssAnimatedCollapsible() { + return ( +
+ + + + + + + + Trigger + + +

This is the collapsed content

+

This is the second paragraph

+

This is a longer sentence and also the third paragraph

+
+
+ +
+ ); +} + +const grey = { + 50: '#F3F6F9', + 100: '#E5EAF2', + 200: '#DAE2ED', + 300: '#C7D0DD', + 400: '#B0B8C4', + 500: '#9DA8B7', + 600: '#6B7A90', + 700: '#434D5B', + 800: '#303740', + 900: '#1C2025', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +export function Styles() { + const isDarkMode = useIsDarkMode(); + return ( + + ); +} diff --git a/docs/data/base/components/collapsible/UnstyledCollapsibleIntroduction.js b/docs/data/base/components/collapsible/UnstyledCollapsibleIntroduction.js new file mode 100644 index 0000000000..f5b96f31fe --- /dev/null +++ b/docs/data/base/components/collapsible/UnstyledCollapsibleIntroduction.js @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { styled, useTheme, Box } from '@mui/system'; +import * as BaseCollapsible from '@base_ui/react/Collapsible'; + +const Collapsible = BaseCollapsible.Root; + +const CollapsibleTrigger = styled(BaseCollapsible.Trigger)` + display: flex; + flex-flow: row nowrap; + justify-content: center; + gap: 4px; + font-size: 16px; + + & svg { + margin-top: 1px; + } + + &[data-state='open'] svg { + transform: rotate(180deg); + } +`; + +const CollapsibleContent = styled(BaseCollapsible.Content)``; + +export default function UnstyledCollapsibleIntroduction() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + const [open, setOpen] = React.useState(true); + return ( + + + + + + + Show {open ? 'less' : 'more'} + + +

+ This is the collapsed content. The element that shows and hides the + content has role button +

+

+ When the content is visible, the element with role `button` has + `aria-expanded` set to `true` +

+

When the content area is hidden, it is set to `false`

+

+ Optionally, the element with role `button` has a value specified for + `aria-controls` that refers to the element that contains all the content + that is shown or hidden +

+
+
+
+ ); +} + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} diff --git a/docs/data/base/components/collapsible/UnstyledCollapsibleIntroduction.tsx b/docs/data/base/components/collapsible/UnstyledCollapsibleIntroduction.tsx new file mode 100644 index 0000000000..f5b96f31fe --- /dev/null +++ b/docs/data/base/components/collapsible/UnstyledCollapsibleIntroduction.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { styled, useTheme, Box } from '@mui/system'; +import * as BaseCollapsible from '@base_ui/react/Collapsible'; + +const Collapsible = BaseCollapsible.Root; + +const CollapsibleTrigger = styled(BaseCollapsible.Trigger)` + display: flex; + flex-flow: row nowrap; + justify-content: center; + gap: 4px; + font-size: 16px; + + & svg { + margin-top: 1px; + } + + &[data-state='open'] svg { + transform: rotate(180deg); + } +`; + +const CollapsibleContent = styled(BaseCollapsible.Content)``; + +export default function UnstyledCollapsibleIntroduction() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + const [open, setOpen] = React.useState(true); + return ( + + + + + + + Show {open ? 'less' : 'more'} + + +

+ This is the collapsed content. The element that shows and hides the + content has role button +

+

+ When the content is visible, the element with role `button` has + `aria-expanded` set to `true` +

+

When the content area is hidden, it is set to `false`

+

+ Optionally, the element with role `button` has a value specified for + `aria-controls` that refers to the element that contains all the content + that is shown or hidden +

+
+
+
+ ); +} + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} diff --git a/docs/data/base/components/collapsible/collapsible.md b/docs/data/base/components/collapsible/collapsible.md index 806953818c..7d75fb25e4 100644 --- a/docs/data/base/components/collapsible/collapsible.md +++ b/docs/data/base/components/collapsible/collapsible.md @@ -16,6 +16,8 @@ packageName: '@base_ui/react' {{"component": "modules/components/ComponentPageTabs.js"}} +{{"demo": "UnstyledCollapsibleIntroduction.js", "defaultCodeOpen": false, "bg": "gradient"}} + ## Installation BaseĀ UI components are all available as a single package. @@ -54,3 +56,73 @@ import * as Collapsible from '@base_ui/react/Collapsible'; This is the content ``` + +## Animations + +The Collapsible component can animate when opening or closing using either: + +- CSS animations +- CSS transitions +- JavaScript animations + +The height of the `Content` is provided as the `--collapsible-content-height` CSS variable + +### CSS Animations + +CSS animations can be used with two declarations: + +```css +.Collapsible-content { + overflow: hidden; +} + +.Collapsible-content[data-state='open'] { + animation: slideDown 300ms ease-out; +} + +.Collapsible-content[data-state='closed'] { + animation: slideUp 300ms ease-in; +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--collapsible-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--collapsible-content-height); + } + to { + height: 0; + } +} +``` + +{{"demo": "CssAnimatedCollapsible.js"}} + +### CSS Transitions + +```css +.Collapsible-content { + overflow: hidden; +} + +.Collapsible-content[data-entering] { + height: 0; +} + +.Collapsible2-content[data-state='open'] { + height: var(--collapsible-content-height); + transition: height 300ms ease-out; +} + +.Collapsible2-content[data-state='closed'] { + height: 0; + transition: height 300ms ease-in; +} +``` diff --git a/docs/pages/experiments/collapsible.tsx b/docs/pages/experiments/collapsible.tsx index 173063cc72..e4c356a1ac 100644 --- a/docs/pages/experiments/collapsible.tsx +++ b/docs/pages/experiments/collapsible.tsx @@ -8,7 +8,7 @@ export default function CollapsibleDemo() { - + @@ -16,6 +16,12 @@ export default function CollapsibleDemo() {

This is the collapsed content

+

+ Your Choice of Fried Chicken (Half), Chicken Sandwich, With Shredded cabbage & carrot + with mustard mayonnaise And Potato Wedges +

+

demo: https://codepen.io/aardrian/pen/QWjBNQG

+

https://adrianroselli.com/2020/05/disclosure-widgets.html

@@ -109,10 +115,33 @@ export function Styles() { } .MyCollapsible-content { - padding: .25em .75em; - border: .1em solid #ddd; - background-color: #fff; - margin-top: -.1em; + background-color: #eaeaea; + overflow: hidden; + } + + .MyCollapsible-content[data-state='open'] { + animation: slideDown 600ms ease-out; + } + .MyCollapsible-content[data-state='closed'] { + animation: slideUp 600ms ease-out; + } + + @keyframes slideDown { + from { + height: 0; + } + to { + height: var(--collapsible-content-height); + } + } + + @keyframes slideUp { + from { + height: var(--collapsible-content-height); + } + to { + height: 0; + } } `} ); diff --git a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.test.tsx b/packages/mui-base/src/Collapsible/Content/CollapsibleContent.test.tsx index f8730ecbf7..749420e5a7 100644 --- a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.test.tsx +++ b/packages/mui-base/src/Collapsible/Content/CollapsibleContent.test.tsx @@ -8,12 +8,16 @@ import type { CollapsibleContextValue } from '../Root/CollapsibleRoot.types'; const contextValue: CollapsibleContextValue = { contentId: 'ContentId', disabled: false, + mounted: true, open: true, setContentId() {}, + setMounted() {}, setOpen() {}, + transitionStatus: undefined, ownerState: { open: true, disabled: false, + transitionStatus: undefined, }, }; diff --git a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.tsx b/packages/mui-base/src/Collapsible/Content/CollapsibleContent.tsx index bb44181404..8767763a2f 100644 --- a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.tsx +++ b/packages/mui-base/src/Collapsible/Content/CollapsibleContent.tsx @@ -13,12 +13,16 @@ const CollapsibleContent = React.forwardRef(function CollapsibleContent( ) { const { className, render, ...otherProps } = props; - const { open, contentId, setContentId, ownerState } = useCollapsibleContext(); + const { mounted, open, contentId, setContentId, setMounted, ownerState } = + useCollapsibleContext(); - const { getRootProps } = useCollapsibleContent({ + const { getRootProps, height } = useCollapsibleContent({ id: contentId, + mounted, open, + ref: forwardedRef, setContentId, + setMounted, }); const { renderElement } = useComponentRenderer({ @@ -26,8 +30,12 @@ const CollapsibleContent = React.forwardRef(function CollapsibleContent( render: render ?? 'div', ownerState, className, - ref: forwardedRef, - extraProps: otherProps, + extraProps: { + ...otherProps, + style: { + '--collapsible-content-height': height ? `${height}px` : undefined, + }, + }, customStyleHookMapping: collapsibleStyleHookMapping, }); diff --git a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.types.ts b/packages/mui-base/src/Collapsible/Content/CollapsibleContent.types.ts index 6c7d72beed..9137b94ac4 100644 --- a/packages/mui-base/src/Collapsible/Content/CollapsibleContent.types.ts +++ b/packages/mui-base/src/Collapsible/Content/CollapsibleContent.types.ts @@ -6,15 +6,20 @@ export interface CollapsibleContentProps export interface UseCollapsibleContentParameters { id?: React.HTMLAttributes['id']; + mounted: boolean; /** * The open state of the Collapsible */ open: boolean; + ref: React.Ref; setContentId: (id: string | undefined) => void; + setMounted: (nextMounted: boolean) => void; } export interface UseCollapsibleContentReturnValue { getRootProps: ( externalProps?: React.ComponentPropsWithRef<'button'>, ) => React.ComponentPropsWithRef<'button'>; + isOpen: boolean; + height: number; } diff --git a/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts b/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts index e521ca0002..cd619396c2 100644 --- a/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts +++ b/packages/mui-base/src/Collapsible/Content/useCollapsibleContent.ts @@ -1,12 +1,23 @@ 'use client'; import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import { mergeReactProps } from '../../utils/mergeReactProps'; +import { ownerWindow } from '../../utils/owner'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useAnimationsFinished } from '../../utils/useAnimationsFinished'; +import { useForkRef } from '../../utils/useForkRef'; import { useId } from '../../utils/useId'; import { UseCollapsibleContentParameters, UseCollapsibleContentReturnValue, } from './CollapsibleContent.types'; + +function getComputedStyles(element: HTMLElement) { + const containerWindow = ownerWindow(element); + return containerWindow.getComputedStyle(element); +} + /** * * Demos: @@ -20,10 +31,24 @@ import { function useCollapsibleContent( parameters: UseCollapsibleContentParameters, ): UseCollapsibleContentReturnValue { - const { id: idParam, open, setContentId } = parameters; + const { + id: idParam, + open, + mounted: contextMounted, + ref, + setContentId, + setMounted: setContextMounted, + } = parameters; const id = useId(idParam); + const contentRef = React.useRef(null); + + const heightRef = React.useRef(0); + const { current: height } = heightRef; + + const latestAnimationNameRef = React.useRef('none'); + useEnhancedEffect(() => { setContentId(id); return () => { @@ -31,18 +56,95 @@ function useCollapsibleContent( }; }, [id, setContentId]); + const handleContentRef = useEventCallback((element: HTMLElement) => { + if (!element) { + return; + } + + contentRef.current = element; + + const computedStyles = getComputedStyles(element); + + latestAnimationNameRef.current = computedStyles.animationName ?? 'none'; + }); + + const mergedRef = useForkRef(ref, handleContentRef); + + const runOnceAnimationsFinish = useAnimationsFinished(contentRef); + + const isOpen = open || contextMounted; + + const isInitialOpenRef = React.useRef(isOpen); + + useEnhancedEffect(() => { + const { current: element } = contentRef; + + if (element) { + // console.group(open ? 'opening' : 'closing'); + // console.log('isInitialOpenRef.current', isInitialOpenRef.current); + const computedStyles = getComputedStyles(element); + + const prevAnimationName = latestAnimationNameRef.current; + const currentAnimationName = computedStyles.animationName; + // console.log('currentAnimationName', currentAnimationName); + // console.log('prevAnimationName', prevAnimationName); + + const originalAnimationName = + element.style.animationName === 'none' ? '' : element.style.animationName; + // console.log('originalAnimationName', originalAnimationName); + + element.style.animationName = 'none'; + + const isAnimatingOut = prevAnimationName !== currentAnimationName && !open && contextMounted; + + if (open || isAnimatingOut) { + const rect = element.getBoundingClientRect(); + heightRef.current = rect.height; + } + + element.style.animationName = isInitialOpenRef.current ? 'none' : originalAnimationName; + + runOnceAnimationsFinish(() => { + ReactDOM.flushSync(() => { + setContextMounted(open); + }); + }); + + if (currentAnimationName !== 'none') { + latestAnimationNameRef.current = currentAnimationName; + } + + // console.log('latestAnimationNameRef.current', latestAnimationNameRef.current); + // console.groupEnd(); + } + }, [open, contextMounted, runOnceAnimationsFinish, setContextMounted]); + + React.useEffect(() => { + const frame = requestAnimationFrame(() => { + isInitialOpenRef.current = false; + }); + + return () => cancelAnimationFrame(frame); + }, []); + const getRootProps: UseCollapsibleContentReturnValue['getRootProps'] = React.useCallback( (externalProps = {}) => mergeReactProps(externalProps, { id, - hidden: open ? undefined : 'hidden', + hidden: isOpen ? undefined : 'hidden', + ref: mergedRef, }), - [id, open], + [id, isOpen, mergedRef], ); - return { - getRootProps, - }; + return React.useMemo( + () => ({ + getRootProps, + isOpen, + height, + }), + [getRootProps, isOpen, height], + ); } export { useCollapsibleContent }; diff --git a/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.test.tsx b/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.test.tsx index 88cf7143ce..cc9aac6d93 100644 --- a/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.test.tsx +++ b/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.test.tsx @@ -45,11 +45,16 @@ describe('', () => { expect(trigger).to.have.attribute('aria-expanded', 'true'); expect(content).to.not.have.attribute('hidden'); expect(content).to.have.attribute('data-state', 'open'); + + setProps({ open: false }); + + expect(trigger).to.have.attribute('aria-expanded', 'false'); + expect(content).to.have.attribute('data-state', 'closed'); }); it('uncontrolled mode', async () => { const { getByTestId, getByRole, user } = await render( - + , @@ -58,6 +63,12 @@ describe('', () => { const trigger = getByRole('button'); const content = getByTestId('content'); + expect(trigger).to.have.attribute('aria-expanded', 'false'); + expect(content).to.have.attribute('hidden'); + expect(content).to.have.attribute('data-state', 'closed'); + + await user.pointer({ keys: '[MouseLeft]', target: trigger }); + expect(trigger).to.have.attribute('aria-expanded', 'true'); expect(content).to.not.have.attribute('hidden'); expect(content).to.have.attribute('data-state', 'open'); @@ -65,16 +76,15 @@ describe('', () => { await user.pointer({ keys: '[MouseLeft]', target: trigger }); expect(trigger).to.have.attribute('aria-expanded', 'false'); - expect(content).to.have.attribute('hidden'); expect(content).to.have.attribute('data-state', 'closed'); }); }); describe('keyboard interactions', () => { - ['Enter'].forEach((key) => { + ['Enter', 'Space'].forEach((key) => { it(`key: ${key} should toggle the Collapsible`, async () => { const { getByTestId, getByRole, user } = await render( - + Trigger , @@ -83,16 +93,21 @@ describe('', () => { const trigger = getByRole('button'); const content = getByTestId('content'); + expect(trigger).to.have.attribute('aria-expanded', 'false'); + expect(content).to.have.attribute('hidden'); + expect(content).to.have.attribute('data-state', 'closed'); + + await user.keyboard('[Tab]'); + expect(trigger).toHaveFocus(); + await user.keyboard(`[${key}]`); + expect(trigger).to.have.attribute('aria-expanded', 'true'); expect(content).to.not.have.attribute('hidden'); expect(content).to.have.attribute('data-state', 'open'); - await user.keyboard('[Tab]'); - expect(trigger).toHaveFocus(); await user.keyboard(`[${key}]`); expect(trigger).to.have.attribute('aria-expanded', 'false'); - expect(content).to.have.attribute('hidden'); expect(content).to.have.attribute('data-state', 'closed'); }); }); diff --git a/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.tsx b/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.tsx index 3a4320c792..5c83b184b3 100644 --- a/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.tsx +++ b/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.tsx @@ -18,7 +18,11 @@ function CollapsibleRoot(props: CollapsibleRootProps) { const contextValue: CollapsibleContextValue = React.useMemo( () => ({ ...collapsible, - ownerState: { open: collapsible.open, disabled: collapsible.disabled }, + ownerState: { + open: collapsible.open, + disabled: collapsible.disabled, + transitionStatus: collapsible.transitionStatus, + }, }), [collapsible], ); diff --git a/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.types.ts b/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.types.ts index 1e56238880..69bfae7985 100644 --- a/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.types.ts +++ b/packages/mui-base/src/Collapsible/Root/CollapsibleRoot.types.ts @@ -1,8 +1,13 @@ +import { TransitionStatus } from '../../utils/useTransitionStatus'; + export interface CollapsibleContextValue extends UseCollapsibleRootReturnValue { - ownerState: Pick; + ownerState: Pick; } -export type CollapsibleRootOwnerState = Pick; +export type CollapsibleRootOwnerState = Pick< + CollapsibleContextValue, + 'open' | 'disabled' | 'transitionStatus' +>; export interface CollapsibleRootProps extends UseCollapsibleRootParameters { children: React.ReactNode; @@ -37,10 +42,13 @@ export interface UseCollapsibleRootReturnValue { * The disabled state of the Collapsible */ disabled: boolean; + mounted: boolean; /** * The open state of the Collapsible */ open: boolean; setContentId: (id: string | undefined) => void; + setMounted: (open: boolean) => void; setOpen: (open: boolean) => void; + transitionStatus: TransitionStatus; } diff --git a/packages/mui-base/src/Collapsible/Root/styleHooks.ts b/packages/mui-base/src/Collapsible/Root/styleHooks.ts index 29af054402..45891e16b8 100644 --- a/packages/mui-base/src/Collapsible/Root/styleHooks.ts +++ b/packages/mui-base/src/Collapsible/Root/styleHooks.ts @@ -5,4 +5,13 @@ export const collapsibleStyleHookMapping: CustomStyleHookMapping { return value ? { 'data-state': 'open' } : { 'data-state': 'closed' }; }, + transitionStatus: (value) => { + if (value === 'entering') { + return { 'data-entering': '' } as Record; + } + if (value === 'exiting') { + return { 'data-exiting': '' }; + } + return null; + }, }; diff --git a/packages/mui-base/src/Collapsible/Root/useCollapsibleRoot.ts b/packages/mui-base/src/Collapsible/Root/useCollapsibleRoot.ts index bf6767fd3d..79213fff0e 100644 --- a/packages/mui-base/src/Collapsible/Root/useCollapsibleRoot.ts +++ b/packages/mui-base/src/Collapsible/Root/useCollapsibleRoot.ts @@ -1,6 +1,8 @@ 'use client'; import * as React from 'react'; import { useControlled } from '../../utils/useControlled'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useTransitionStatus } from '../../utils/useTransitionStatus'; import { useId } from '../../utils/useId'; import { UseCollapsibleRootParameters, @@ -21,27 +23,35 @@ function useCollapsibleRoot( ): UseCollapsibleRootReturnValue { const { open: openParam, defaultOpen = true, onOpenChange, disabled = false } = parameters; - const [open, setOpen] = useControlled({ + const [open, setOpenState] = useControlled({ controlled: openParam, default: defaultOpen, - name: 'CollapsibleRoot', + name: 'Collapsible', + state: 'open', }); + const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, true); // TODO: the 2nd argument should be an `animated` prop? + const [contentId, setContentId] = React.useState(useId()); - React.useEffect(() => { - if (onOpenChange) { - onOpenChange(open); - } - }, [onOpenChange, open]); + const setOpen = useEventCallback((nextOpen: boolean) => { + onOpenChange?.(nextOpen); + setOpenState(nextOpen); + }); - return { - contentId, - disabled, - open, - setContentId, - setOpen, - }; + return React.useMemo( + () => ({ + contentId, + disabled, + mounted, + open, + setContentId, + setMounted, + setOpen, + transitionStatus, + }), + [contentId, disabled, mounted, open, setContentId, setMounted, setOpen, transitionStatus], + ); } export { useCollapsibleRoot }; diff --git a/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.test.tsx b/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.test.tsx index f722bcae1a..a206fdbbd7 100644 --- a/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.test.tsx +++ b/packages/mui-base/src/Collapsible/Trigger/CollapsibleTrigger.test.tsx @@ -8,12 +8,16 @@ import type { CollapsibleContextValue } from '../Root/CollapsibleRoot.types'; const contextValue: CollapsibleContextValue = { contentId: 'ContentId', disabled: false, + mounted: true, open: true, setContentId() {}, + setMounted() {}, setOpen() {}, + transitionStatus: undefined, ownerState: { open: true, disabled: false, + transitionStatus: undefined, }, };