Skip to content

Commit d57bd8d

Browse files
reidbarbersnowystingerdevongovett
authored
fix+tests: fix Disclosure bugs and add tests (#7096)
* add disclosure tests * lint/cleanup * Pass isExpanded to useDisclosure as well --------- Co-authored-by: Robert Snow <[email protected]> Co-authored-by: Devon Govett <[email protected]>
1 parent 0b314ea commit d57bd8d

File tree

6 files changed

+799
-7
lines changed

6 files changed

+799
-7
lines changed

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,15 @@ export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState
5151
let supportsBeforeMatch = !isSSR && 'onbeforematch' in document.body;
5252

5353
// @ts-ignore https://github.com/facebook/react/pull/24741
54-
useEvent(ref, 'beforematch', supportsBeforeMatch ? () => state.expand() : null);
54+
useEvent(ref, 'beforematch', supportsBeforeMatch && !isControlled ? () => state.expand() : null);
5555

5656
useEffect(() => {
5757
// Until React supports hidden="until-found": https://github.com/facebook/react/pull/24741
5858
if (supportsBeforeMatch && ref?.current && !isControlled && !isDisabled) {
5959
if (state.isExpanded) {
60-
// @ts-ignore
61-
ref.current.hidden = undefined;
60+
ref.current.removeAttribute('hidden');
6261
} else {
63-
// @ts-ignore
64-
ref.current.hidden = 'until-found';
62+
ref.current.setAttribute('hidden', 'until-found');
6563
}
6664
}
6765
}, [isControlled, ref, props.isExpanded, state, supportsBeforeMatch, isDisabled]);
@@ -72,7 +70,7 @@ export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState
7270
'aria-expanded': state.isExpanded,
7371
'aria-controls': contentId,
7472
onPress: (e) => {
75-
if (e.pointerType !== 'keyboard') {
73+
if (!isDisabled && e.pointerType !== 'keyboard') {
7674
state.toggle();
7775
}
7876
},
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import {actHook as act, renderHook} from '@react-spectrum/test-utils-internal';
13+
import {KeyboardEvent, PressEvent} from '@react-types/shared';
14+
import {useDisclosure} from '../src/useDisclosure';
15+
import {useDisclosureState} from '@react-stately/disclosure';
16+
17+
describe('useDisclosure', () => {
18+
let defaultProps = {};
19+
let ref = {current: document.createElement('div')};
20+
21+
afterEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
it('should return correct aria attributes when collapsed', () => {
26+
let {result} = renderHook(() => {
27+
let state = useDisclosureState(defaultProps);
28+
return useDisclosure({}, state, ref);
29+
});
30+
31+
let {buttonProps, panelProps} = result.current;
32+
33+
expect(buttonProps['aria-expanded']).toBe(false);
34+
expect(panelProps.hidden).toBe(true);
35+
});
36+
37+
it('should return correct aria attributes when expanded', () => {
38+
let {result} = renderHook(() => {
39+
let state = useDisclosureState({defaultExpanded: true});
40+
return useDisclosure({}, state, ref);
41+
});
42+
43+
let {buttonProps, panelProps} = result.current;
44+
45+
expect(buttonProps['aria-expanded']).toBe(true);
46+
expect(panelProps.hidden).toBe(false);
47+
});
48+
49+
it('should handle expanding on press event', () => {
50+
let {result} = renderHook(() => {
51+
let state = useDisclosureState({});
52+
let disclosure = useDisclosure({}, state, ref);
53+
return {state, disclosure};
54+
});
55+
56+
act(() => {
57+
result.current.disclosure.buttonProps.onPress?.({pointerType: 'mouse'} as PressEvent);
58+
});
59+
60+
expect(result.current.state.isExpanded).toBe(true);
61+
});
62+
63+
it('should handle expanding on keydown event', () => {
64+
let {result} = renderHook(() => {
65+
let state = useDisclosureState({});
66+
let disclosure = useDisclosure({}, state, ref);
67+
return {state, disclosure};
68+
});
69+
70+
let preventDefault = jest.fn();
71+
let event = (e: Partial<KeyboardEvent>) => ({...e, preventDefault} as KeyboardEvent);
72+
73+
act(() => {
74+
result.current.disclosure.buttonProps.onKeyDown?.(event({key: 'Enter', preventDefault}) as KeyboardEvent);
75+
});
76+
77+
expect(preventDefault).toHaveBeenCalledTimes(1);
78+
79+
expect(result.current.state.isExpanded).toBe(true);
80+
});
81+
82+
it('should not toggle when disabled', () => {
83+
let {result} = renderHook(() => {
84+
let state = useDisclosureState({});
85+
let disclosure = useDisclosure({isDisabled: true}, state, ref);
86+
return {state, disclosure};
87+
});
88+
89+
act(() => {
90+
result.current.disclosure.buttonProps.onPress?.({pointerType: 'mouse'} as PressEvent);
91+
});
92+
93+
expect(result.current.state.isExpanded).toBe(false);
94+
});
95+
96+
it('should set correct IDs for accessibility', () => {
97+
let {result} = renderHook(() => {
98+
let state = useDisclosureState({});
99+
return useDisclosure({}, state, ref);
100+
});
101+
102+
let {buttonProps, panelProps} = result.current;
103+
104+
expect(buttonProps['aria-controls']).toBe(panelProps.id);
105+
expect(panelProps['aria-labelledby']).toBe(buttonProps.id);
106+
});
107+
108+
it('should expand when beforematch event occurs', () => {
109+
// Mock 'onbeforematch' support on document.body
110+
// @ts-ignore
111+
const originalOnBeforeMatch = document.body.onbeforematch;
112+
Object.defineProperty(document.body, 'onbeforematch', {
113+
value: null,
114+
writable: true,
115+
configurable: true
116+
});
117+
118+
const ref = {current: document.createElement('div')};
119+
120+
const {result} = renderHook(() => {
121+
const state = useDisclosureState({});
122+
const disclosure = useDisclosure({}, state, ref);
123+
return {state, disclosure};
124+
});
125+
126+
expect(result.current.state.isExpanded).toBe(false);
127+
expect(ref.current.getAttribute('hidden')).toBe('until-found');
128+
129+
// Simulate the 'beforematch' event
130+
act(() => {
131+
const event = new Event('beforematch', {bubbles: true});
132+
ref.current.dispatchEvent(event);
133+
});
134+
135+
expect(result.current.state.isExpanded).toBe(true);
136+
expect(ref.current.hasAttribute('hidden')).toBe(false);
137+
138+
Object.defineProperty(document.body, 'onbeforematch', {
139+
value: originalOnBeforeMatch,
140+
writable: true,
141+
configurable: true
142+
});
143+
});
144+
145+
it('should not expand when beforematch event occurs if controlled and closed', () => {
146+
// Mock 'onbeforematch' support on document.body
147+
// @ts-ignore
148+
const originalOnBeforeMatch = document.body.onbeforematch;
149+
Object.defineProperty(document.body, 'onbeforematch', {
150+
value: null,
151+
writable: true,
152+
configurable: true
153+
});
154+
155+
const ref = {current: document.createElement('div')};
156+
157+
const onExpandedChange = jest.fn();
158+
159+
const {result} = renderHook(() => {
160+
const state = useDisclosureState({isExpanded: false, onExpandedChange});
161+
const disclosure = useDisclosure({isExpanded: false}, state, ref);
162+
return {state, disclosure};
163+
});
164+
165+
expect(result.current.state.isExpanded).toBe(false);
166+
expect(ref.current.getAttribute('hidden')).toBeNull();
167+
168+
// Simulate the 'beforematch' event
169+
act(() => {
170+
const event = new Event('beforematch', {bubbles: true});
171+
ref.current.dispatchEvent(event);
172+
});
173+
174+
expect(result.current.state.isExpanded).toBe(false);
175+
expect(ref.current.getAttribute('hidden')).toBeNull();
176+
expect(onExpandedChange).not.toHaveBeenCalled();
177+
178+
Object.defineProperty(document.body, 'onbeforematch', {
179+
value: originalOnBeforeMatch,
180+
writable: true,
181+
configurable: true
182+
});
183+
});
184+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {actHook as act, renderHook} from '@react-spectrum/test-utils-internal';
14+
import {useDisclosureGroupState} from '../src/useDisclosureGroupState';
15+
16+
describe('useDisclosureGroupState', () => {
17+
it('should initialize with empty expandedKeys when not provided', () => {
18+
const {result} = renderHook(() => useDisclosureGroupState({}));
19+
expect(result.current.expandedKeys.size).toBe(0);
20+
});
21+
22+
it('should initialize with defaultExpandedKeys when provided', () => {
23+
const {result} = renderHook(() =>
24+
useDisclosureGroupState({defaultExpandedKeys: ['item1']})
25+
);
26+
expect(result.current.expandedKeys.has('item1')).toBe(true);
27+
expect(result.current.expandedKeys.has('item2')).toBe(false);
28+
});
29+
30+
it('should initialize with multiple defaultExpandedKeys when provided, and if allowsMultipleExpanded is true', () => {
31+
const {result} = renderHook(() =>
32+
useDisclosureGroupState({defaultExpandedKeys: ['item1', 'item2'], allowsMultipleExpanded: true})
33+
);
34+
expect(result.current.expandedKeys.has('item1')).toBe(true);
35+
expect(result.current.expandedKeys.has('item2')).toBe(true);
36+
});
37+
38+
it('should allow controlled expandedKeys prop', () => {
39+
const {result, rerender} = renderHook(
40+
({expandedKeys}) => useDisclosureGroupState({expandedKeys}),
41+
{initialProps: {expandedKeys: ['item1']}}
42+
);
43+
expect(result.current.expandedKeys.has('item1')).toBe(true);
44+
45+
rerender({expandedKeys: ['item2']});
46+
expect(result.current.expandedKeys.has('item1')).toBe(false);
47+
expect(result.current.expandedKeys.has('item2')).toBe(true);
48+
});
49+
50+
it('should toggle key correctly when allowsMultipleExpanded is false', () => {
51+
const {result} = renderHook(() => useDisclosureGroupState({}));
52+
act(() => {
53+
result.current.toggleKey('item1');
54+
});
55+
expect(result.current.expandedKeys.has('item1')).toBe(true);
56+
57+
act(() => {
58+
result.current.toggleKey('item2');
59+
});
60+
expect(result.current.expandedKeys.has('item1')).toBe(false);
61+
expect(result.current.expandedKeys.has('item2')).toBe(true);
62+
});
63+
64+
it('should toggle key correctly when allowsMultipleExpanded is true', () => {
65+
const {result} = renderHook(() =>
66+
useDisclosureGroupState({allowsMultipleExpanded: true})
67+
);
68+
act(() => {
69+
result.current.toggleKey('item1');
70+
});
71+
expect(result.current.expandedKeys.has('item1')).toBe(true);
72+
73+
act(() => {
74+
result.current.toggleKey('item2');
75+
});
76+
expect(result.current.expandedKeys.has('item1')).toBe(true);
77+
expect(result.current.expandedKeys.has('item2')).toBe(true);
78+
});
79+
80+
it('should call onExpandedChange when expanded keys change', () => {
81+
const onExpandedChange = jest.fn();
82+
const {result} = renderHook(() =>
83+
useDisclosureGroupState({onExpandedChange})
84+
);
85+
86+
act(() => {
87+
result.current.toggleKey('item1');
88+
});
89+
expect(onExpandedChange).toHaveBeenCalledWith(new Set(['item1']));
90+
});
91+
92+
it('should not expand more than one key when allowsMultipleExpanded is false', () => {
93+
const {result} = renderHook(() => useDisclosureGroupState({}));
94+
act(() => {
95+
result.current.toggleKey('item1');
96+
result.current.toggleKey('item2');
97+
});
98+
expect(result.current.expandedKeys.size).toBe(1);
99+
expect(result.current.expandedKeys.has('item2')).toBe(true);
100+
});
101+
102+
it('should respect isDisabled prop', () => {
103+
const {result} = renderHook(() => useDisclosureGroupState({isDisabled: true}));
104+
expect(result.current.isDisabled).toBe(true);
105+
});
106+
});

0 commit comments

Comments
 (0)