Skip to content

Commit b887497

Browse files
authored
fix: Support hidden="until-found" in DisclosureGroup (#7199)
* support hidden="until-found" in DisclosureGroup * typescript * cleanup * useLayoutEffect and RAF * add RAF/flushSync * lint * add comments * remove extra changes from merge * revert newlines * typescript * more typescript * use DisclosureTitle
1 parent 0ddbe6f commit b887497

File tree

5 files changed

+149
-13
lines changed

5 files changed

+149
-13
lines changed

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

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212

1313
import {AriaButtonProps} from '@react-types/button';
1414
import {DisclosureState} from '@react-stately/disclosure';
15-
import {HTMLAttributes, RefObject, useEffect} from 'react';
16-
import {useEvent, useId} from '@react-aria/utils';
15+
import {flushSync} from 'react-dom';
16+
import {HTMLAttributes, RefObject, useCallback, useEffect, useRef} from 'react';
17+
import {useEvent, useId, useLayoutEffect} from '@react-aria/utils';
1718
import {useIsSSR} from '@react-aria/ssr';
1819

1920
export interface AriaDisclosureProps {
@@ -40,29 +41,55 @@ export interface DisclosureAria {
4041
* @param state - State for the disclosure, as returned by `useDisclosureState`.
4142
* @param ref - A ref for the disclosure content.
4243
*/
43-
export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState, ref?: RefObject<Element | null>): DisclosureAria {
44+
export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState, ref: RefObject<Element | null>): DisclosureAria {
4445
let {
4546
isDisabled
4647
} = props;
4748
let triggerId = useId();
4849
let contentId = useId();
49-
let isControlled = props.isExpanded !== undefined;
5050
let isSSR = useIsSSR();
5151
let supportsBeforeMatch = !isSSR && 'onbeforematch' in document.body;
5252

53+
let raf = useRef<number | null>(null);
54+
55+
let handleBeforeMatch = useCallback(() => {
56+
// Wait a frame to revert browser's removal of hidden attribute
57+
raf.current = requestAnimationFrame(() => {
58+
if (ref.current) {
59+
ref.current.setAttribute('hidden', 'until-found');
60+
}
61+
});
62+
// Force sync state update
63+
flushSync(() => {
64+
state.toggle();
65+
});
66+
}, [ref, state]);
67+
5368
// @ts-ignore https://github.com/facebook/react/pull/24741
54-
useEvent(ref, 'beforematch', supportsBeforeMatch && !isControlled ? () => state.expand() : null);
69+
useEvent(ref, 'beforematch', supportsBeforeMatch ? handleBeforeMatch : null);
5570

56-
useEffect(() => {
71+
useLayoutEffect(() => {
72+
// Cancel any pending RAF to prevent stale updates
73+
if (raf.current) {
74+
cancelAnimationFrame(raf.current);
75+
}
5776
// Until React supports hidden="until-found": https://github.com/facebook/react/pull/24741
58-
if (supportsBeforeMatch && ref?.current && !isControlled && !isDisabled) {
77+
if (supportsBeforeMatch && ref.current && !isDisabled) {
5978
if (state.isExpanded) {
6079
ref.current.removeAttribute('hidden');
6180
} else {
6281
ref.current.setAttribute('hidden', 'until-found');
6382
}
6483
}
65-
}, [isControlled, ref, props.isExpanded, state, supportsBeforeMatch, isDisabled]);
84+
}, [isDisabled, ref, state.isExpanded, supportsBeforeMatch]);
85+
86+
useEffect(() => {
87+
return () => {
88+
if (raf.current) {
89+
cancelAnimationFrame(raf.current);
90+
}
91+
};
92+
}, []);
6693

6794
return {
6895
buttonProps: {
@@ -87,7 +114,7 @@ export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState
87114
// This can be overridden at the panel element level.
88115
role: 'group',
89116
'aria-labelledby': triggerId,
90-
hidden: (!supportsBeforeMatch || isControlled) ? !state.isExpanded : true
117+
hidden: supportsBeforeMatch ? true : !state.isExpanded
91118
}
92119
};
93120
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ describe('useDisclosure', () => {
163163
});
164164

165165
expect(result.current.state.isExpanded).toBe(false);
166-
expect(ref.current.getAttribute('hidden')).toBeNull();
166+
expect(ref.current.getAttribute('hidden')).toBe('until-found');
167167

168168
// Simulate the 'beforematch' event
169169
act(() => {
@@ -172,8 +172,9 @@ describe('useDisclosure', () => {
172172
});
173173

174174
expect(result.current.state.isExpanded).toBe(false);
175-
expect(ref.current.getAttribute('hidden')).toBeNull();
176-
expect(onExpandedChange).not.toHaveBeenCalled();
175+
expect(ref.current.getAttribute('hidden')).toBe('until-found');
176+
expect(onExpandedChange).toHaveBeenCalledTimes(1);
177+
expect(onExpandedChange).toHaveBeenCalledWith(true);
177178

178179
Object.defineProperty(document.body, 'onbeforematch', {
179180
value: originalOnBeforeMatch,

packages/@react-spectrum/s2/stories/Accordion.stories.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import {Accordion, ActionButton, Disclosure, DisclosureHeader, DisclosurePanel, DisclosureTitle, TextField} from '../src';
14+
import {Key} from 'react-aria';
1415
import type {Meta, StoryObj} from '@storybook/react';
1516
import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg';
1617
import React from 'react';
@@ -130,6 +131,68 @@ WithDisabledDisclosure.parameters = {
130131
}
131132
};
132133

134+
function ControlledAccordion(props) {
135+
let [expandedKeys, setExpandedKeys] = React.useState<Set<Key>>(new Set(['people']));
136+
return (
137+
<div className={style({font: 'body', display: 'flex', flexDirection: 'column', gap: 8})}>
138+
<Accordion
139+
onExpandedChange={setExpandedKeys}
140+
expandedKeys={expandedKeys}
141+
{...props}>
142+
<Disclosure id="files">
143+
<DisclosureTitle>
144+
Files
145+
</DisclosureTitle>
146+
<DisclosurePanel>
147+
Files content
148+
</DisclosurePanel>
149+
</Disclosure>
150+
<Disclosure id="people">
151+
<DisclosureTitle>
152+
People
153+
</DisclosureTitle>
154+
<DisclosurePanel>
155+
<TextField label="Name" />
156+
</DisclosurePanel>
157+
</Disclosure>
158+
</Accordion>
159+
<div>Expanded keys: {expandedKeys.size ? Array.from(expandedKeys).join(', ') : 'none'}</div>
160+
</div>
161+
);
162+
}
163+
164+
export const Controlled: Story = {
165+
render: () => <ControlledAccordion />
166+
};
167+
168+
function ControlledOpenAccordion() {
169+
return (
170+
<Accordion
171+
expandedKeys={['people']}>
172+
<Disclosure id="files">
173+
<DisclosureTitle>
174+
Files
175+
</DisclosureTitle>
176+
<DisclosurePanel>
177+
Files content
178+
</DisclosurePanel>
179+
</Disclosure>
180+
<Disclosure id="people">
181+
<DisclosureTitle>
182+
People
183+
</DisclosureTitle>
184+
<DisclosurePanel>
185+
<TextField label="Name" />
186+
</DisclosurePanel>
187+
</Disclosure>
188+
</Accordion>
189+
);
190+
}
191+
192+
export const ControlledOpen: Story = {
193+
render: () => <ControlledOpenAccordion />
194+
};
195+
133196
export const WithActionButton: Story = {
134197
render: (args) => {
135198
return (
@@ -162,3 +225,4 @@ export const WithActionButton: Story = {
162225
);
163226
}
164227
};
228+

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,49 @@ WithLongTitle.parameters = {
8686
}
8787
};
8888

89+
function ControlledDisclosure(props) {
90+
let [isExpanded, setExpanded] = React.useState(false);
91+
return (
92+
<Disclosure {...props} isExpanded={isExpanded} onExpandedChange={setExpanded}>
93+
<DisclosureTitle>
94+
Files
95+
</DisclosureTitle>
96+
<DisclosurePanel>
97+
Files content
98+
</DisclosurePanel>
99+
</Disclosure>
100+
);
101+
}
102+
103+
export const Controlled: Story = {
104+
render: (args) => <ControlledDisclosure {...args} />
105+
};
106+
107+
Controlled.parameters = {
108+
docs: {
109+
disable: true
110+
}
111+
};
112+
113+
export const ControlledClosed: Story = {
114+
render: (args) => (
115+
<Disclosure isExpanded={false} {...args}>
116+
<DisclosureTitle>
117+
Files
118+
</DisclosureTitle>
119+
<DisclosurePanel>
120+
Files content
121+
</DisclosurePanel>
122+
</Disclosure>
123+
)
124+
};
125+
126+
ControlledClosed.parameters = {
127+
docs: {
128+
disable: true
129+
}
130+
};
131+
89132
export const WithActionButton: Story = {
90133
render: (args) => {
91134
return (
@@ -105,3 +148,4 @@ export const WithActionButton: Story = {
105148
);
106149
}
107150
};
151+

packages/react-aria-components/src/Disclosure.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ const InternalDisclosureContext = createContext<InternalDisclosureContextValue |
108108

109109
function Disclosure(props: DisclosureProps, ref: ForwardedRef<HTMLDivElement>) {
110110
[props, ref] = useContextProps(props, ref, DisclosureContext);
111-
let groupState = useContext(DisclosureGroupStateContext);
111+
let groupState = useContext(DisclosureGroupStateContext)!;
112112
let {id, ...otherProps} = props;
113113

114114
// Generate an id if one wasn't provided.

0 commit comments

Comments
 (0)