Skip to content

Commit f36100b

Browse files
authored
feat: Add support for disclosure animation (#8867)
1 parent 6e467f8 commit f36100b

File tree

7 files changed

+97
-41
lines changed

7 files changed

+97
-41
lines changed

packages/@react-aria/disclosure/src/useDisclosure.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,13 @@ export interface DisclosureAria {
4141
* @param state - State for the disclosure, as returned by `useDisclosureState`.
4242
* @param ref - A ref for the disclosure panel.
4343
*/
44-
export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState, ref: RefObject<Element | null>): DisclosureAria {
44+
export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState, ref: RefObject<HTMLElement | null>): DisclosureAria {
4545
let {
4646
isDisabled
4747
} = props;
4848
let triggerId = useId();
4949
let panelId = useId();
5050
let isSSR = useIsSSR();
51-
let supportsBeforeMatch = !isSSR && 'onbeforematch' in document.body;
5251

5352
let raf = useRef<number | null>(null);
5453

@@ -66,22 +65,64 @@ export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState
6665
}, [ref, state]);
6766

6867
// @ts-ignore https://github.com/facebook/react/pull/24741
69-
useEvent(ref, 'beforematch', supportsBeforeMatch ? handleBeforeMatch : null);
68+
useEvent(ref, 'beforematch', handleBeforeMatch);
7069

70+
let isExpandedRef = useRef<boolean | null>(null);
7171
useLayoutEffect(() => {
7272
// Cancel any pending RAF to prevent stale updates
7373
if (raf.current) {
7474
cancelAnimationFrame(raf.current);
7575
}
76-
// Until React supports hidden="until-found": https://github.com/facebook/react/pull/24741
77-
if (supportsBeforeMatch && ref.current && !isDisabled) {
78-
if (state.isExpanded) {
79-
ref.current.removeAttribute('hidden');
80-
} else {
81-
ref.current.setAttribute('hidden', 'until-found');
76+
if (ref.current && !isDisabled && !isSSR) {
77+
let panel = ref.current;
78+
79+
if (isExpandedRef.current == null || typeof panel.getAnimations !== 'function') {
80+
// On initial render (and in tests), set attributes without animation.
81+
if (state.isExpanded) {
82+
panel.removeAttribute('hidden');
83+
panel.style.setProperty('--disclosure-panel-width', 'auto');
84+
panel.style.setProperty('--disclosure-panel-height', 'auto');
85+
} else {
86+
panel.setAttribute('hidden', 'until-found');
87+
panel.style.setProperty('--disclosure-panel-width', '0px');
88+
panel.style.setProperty('--disclosure-panel-height', '0px');
89+
}
90+
} else if (state.isExpanded !== isExpandedRef.current) {
91+
if (state.isExpanded) {
92+
panel.removeAttribute('hidden');
93+
94+
// Set the width and height as pixels so they can be animated.
95+
panel.style.setProperty('--disclosure-panel-width', panel.scrollWidth + 'px');
96+
panel.style.setProperty('--disclosure-panel-height', panel.scrollHeight + 'px');
97+
98+
Promise.all(panel.getAnimations().map(a => a.finished))
99+
.then(() => {
100+
// After the animations complete, switch back to auto so the content can resize.
101+
panel.style.setProperty('--disclosure-panel-width', 'auto');
102+
panel.style.setProperty('--disclosure-panel-height', 'auto');
103+
})
104+
.catch(() => {});
105+
} else {
106+
panel.style.setProperty('--disclosure-panel-width', panel.scrollWidth + 'px');
107+
panel.style.setProperty('--disclosure-panel-height', panel.scrollHeight + 'px');
108+
109+
// Force style re-calculation to trigger animations.
110+
window.getComputedStyle(panel).height;
111+
112+
// Animate to zero size.
113+
panel.style.setProperty('--disclosure-panel-width', '0px');
114+
panel.style.setProperty('--disclosure-panel-height', '0px');
115+
116+
// Wait for animations to apply the hidden attribute.
117+
Promise.all(panel.getAnimations().map(a => a.finished))
118+
.then(() => panel.setAttribute('hidden', 'until-found'))
119+
.catch(() => {});
120+
}
82121
}
122+
123+
isExpandedRef.current = state.isExpanded;
83124
}
84-
}, [isDisabled, ref, state.isExpanded, supportsBeforeMatch]);
125+
}, [isDisabled, ref, state.isExpanded, isSSR]);
85126

86127
useEffect(() => {
87128
return () => {
@@ -114,7 +155,7 @@ export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState
114155
role: 'group',
115156
'aria-labelledby': triggerId,
116157
'aria-hidden': !state.isExpanded,
117-
hidden: supportsBeforeMatch ? true : !state.isExpanded
158+
hidden: isSSR ? !state.isExpanded : undefined
118159
}
119160
};
120161
}

packages/@react-aria/disclosure/test/useDisclosure.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ describe('useDisclosure', () => {
3131
let {buttonProps, panelProps} = result.current;
3232

3333
expect(buttonProps['aria-expanded']).toBe(false);
34-
expect(panelProps.hidden).toBe(true);
3534
expect(panelProps['aria-hidden']).toBe(true);
3635
});
3736

@@ -44,7 +43,7 @@ describe('useDisclosure', () => {
4443
let {buttonProps, panelProps} = result.current;
4544

4645
expect(buttonProps['aria-expanded']).toBe(true);
47-
expect(panelProps.hidden).toBe(false);
46+
expect(panelProps['aria-hidden']).toBe(false);
4847
});
4948

5049
it('should handle expanding on press event (with mouse)', () => {

packages/@react-spectrum/s2/src/Disclosure.tsx

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -297,20 +297,20 @@ export interface DisclosurePanelProps extends Omit<RACDisclosurePanelProps, 'cla
297297

298298
const panelStyles = style({
299299
font: 'body',
300-
paddingTop: {
301-
isExpanded: 8
302-
},
303-
paddingBottom: {
304-
isExpanded: 16
305-
},
300+
height: '--disclosure-panel-height',
301+
overflow: 'clip',
302+
transition: '[height]'
303+
});
304+
305+
const panelInner = style({
306+
paddingTop: 8,
307+
paddingBottom: 16,
306308
paddingX: {
307-
isExpanded: {
308-
size: {
309-
S: 8,
310-
M: space(9),
311-
L: 12,
312-
XL: space(15)
313-
}
309+
size: {
310+
S: 8,
311+
M: space(9),
312+
L: 12,
313+
XL: space(15)
314314
}
315315
}
316316
});
@@ -326,15 +326,16 @@ export const DisclosurePanel = forwardRef(function DisclosurePanel(props: Disclo
326326
} = props;
327327
const domProps = filterDOMProps(otherProps);
328328
let {size} = useSlottedContext(DisclosureContext)!;
329-
let {isExpanded} = useContext(DisclosureStateContext)!;
330329
let panelRef = useDOMRef(ref);
331330
return (
332331
<RACDisclosurePanel
333332
{...domProps}
334333
ref={panelRef}
335334
style={UNSAFE_style}
336-
className={(UNSAFE_className ?? '') + panelStyles({size, isExpanded})}>
337-
{props.children}
335+
className={(UNSAFE_className ?? '') + panelStyles}>
336+
<div className={panelInner({size})}>
337+
{props.children}
338+
</div>
338339
</RACDisclosurePanel>
339340
);
340341
});

packages/react-aria-components/docs/Disclosure.mdx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {ChevronRight} from 'lucide-react';
5050
</Button>
5151
</Heading>
5252
<DisclosurePanel>
53-
<p>Details about system requirements here.</p>
53+
Details about system requirements here.
5454
</DisclosurePanel>
5555
</Disclosure>
5656
```
@@ -74,6 +74,7 @@ import {ChevronRight} from 'lucide-react';
7474
display: flex;
7575
align-items: center;
7676
gap: 8px;
77+
padding: 8px 0;
7778

7879
svg {
7980
rotate: 0deg;
@@ -84,13 +85,20 @@ import {ChevronRight} from 'lucide-react';
8485
}
8586
}
8687

88+
.react-aria-Heading {
89+
margin-bottom: 0;
90+
}
91+
8792
&[data-expanded] .react-aria-Button[slot=trigger] svg {
8893
rotate: 90deg;
8994
}
9095
}
9196

9297
.react-aria-DisclosurePanel {
93-
margin-left: 32px;
98+
margin-left: 26px;
99+
height: var(--disclosure-panel-height);
100+
transition: height 250ms;
101+
overflow: clip;
94102
}
95103
```
96104

@@ -149,7 +157,7 @@ function MyDisclosure({title, children, ...props}: MyDisclosureProps) {
149157
</Button>
150158
</Heading>
151159
<DisclosurePanel>
152-
<p>{children}</p>
160+
{children}
153161
</DisclosurePanel>
154162
</Disclosure>
155163
)
@@ -213,7 +221,7 @@ In some use cases, you may want to add an interactive element, like a button, ad
213221
<Button>Click me</Button>
214222
</div>
215223
<DisclosurePanel>
216-
<p>Details about system requirements here.</p>
224+
Details about system requirements here.
217225
</DisclosurePanel>
218226
</Disclosure>
219227
```
@@ -282,6 +290,8 @@ A `Disclosure` can be targeted with the `.react-aria-Disclosure` CSS selector, o
282290

283291
<StateTable properties={docs.exports.DisclosureRenderProps.properties} />
284292

293+
Use the `--disclosure-panel-width` and `--disclosure-panel-height` CSS variables to implement animations.
294+
285295
### Button
286296

287297
A `Button` can be targeted with the `.react-aria-Button` CSS selector, or by overriding with a custom `className`. It supports the following states:

packages/react-aria-components/docs/DisclosureGroup.mdx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {ChevronRight} from 'lucide-react';
5353
</Button>
5454
</Heading>
5555
<DisclosurePanel>
56-
<p>Personal information form here.</p>
56+
Personal information form here.
5757
</DisclosurePanel>
5858
</Disclosure>
5959
<Disclosure id="billing">
@@ -64,7 +64,7 @@ import {ChevronRight} from 'lucide-react';
6464
</Button>
6565
</Heading>
6666
<DisclosurePanel>
67-
<p>Billing address form here.</p>
67+
Billing address form here.
6868
</DisclosurePanel>
6969
</Disclosure>
7070
</DisclosureGroup>
@@ -139,7 +139,7 @@ function MyDisclosure({title, children, ...props}: MyDisclosureProps) {
139139
</Button>
140140
</Heading>
141141
<DisclosurePanel>
142-
<p>{children}</p>
142+
{children}
143143
</DisclosurePanel>
144144
</Disclosure>
145145
)
@@ -228,7 +228,7 @@ In some use cases, you may want to add an interactive element, like a button, ad
228228
<Button>Click me</Button>
229229
</div>
230230
<DisclosurePanel>
231-
<p>Details about system requirements here.</p>
231+
Details about system requirements here.
232232
</DisclosurePanel>
233233
</Disclosure>
234234
<Disclosure id="personal">
@@ -242,7 +242,7 @@ In some use cases, you may want to add an interactive element, like a button, ad
242242
<Button>Click me</Button>
243243
</div>
244244
<DisclosurePanel>
245-
<p>Details about personal information here.</p>
245+
Details about personal information here.
246246
</DisclosurePanel>
247247
</Disclosure>
248248
</DisclosureGroup>
@@ -329,6 +329,8 @@ A `Disclosure` can be targeted with the `.react-aria-Disclosure` CSS selector, o
329329

330330
<StateTable properties={docs.exports.DisclosureRenderProps.properties} />
331331

332+
Use the `--disclosure-panel-width` and `--disclosure-panel-height` CSS variables to implement animations.
333+
332334
### Button
333335

334336
A `Button` can be targeted with the `.react-aria-Button` CSS selector, or by overriding with a custom `className`. It supports the following states:

starters/docs/src/Disclosure.css

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
}
3434

3535
.react-aria-DisclosurePanel {
36-
margin-left: 32px;
36+
margin-left: 26px;
3737
color: var(--text-color);
38+
height: var(--disclosure-panel-height);
39+
transition: height 250ms;
40+
overflow: clip;
3841
}

starters/tailwind/src/Disclosure.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ export interface DisclosurePanelProps extends AriaDisclosurePanelProps {
9797

9898
export function DisclosurePanel({ children, ...props }: DisclosurePanelProps) {
9999
return (
100-
<AriaDisclosurePanel {...props} className={composeTailwindRenderProps(props.className, 'group-data-[expanded]:px-4 group-data-[expanded]:py-2')}>
101-
{children}
100+
<AriaDisclosurePanel {...props} className={composeTailwindRenderProps(props.className, 'h-(--disclosure-panel-height) transition-[height] overflow-clip')}>
101+
<div className="px-4 py-2">{children}</div>
102102
</AriaDisclosurePanel>
103103
);
104104
}

0 commit comments

Comments
 (0)