Skip to content

Commit

Permalink
fix+tests: fix Disclosure bugs and add tests (#7096)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
3 people authored Sep 26, 2024
1 parent 0b314ea commit d57bd8d
Show file tree
Hide file tree
Showing 6 changed files with 799 additions and 7 deletions.
10 changes: 4 additions & 6 deletions packages/@react-aria/disclosure/src/useDisclosure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,15 @@ export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState
let supportsBeforeMatch = !isSSR && 'onbeforematch' in document.body;

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

useEffect(() => {
// Until React supports hidden="until-found": https://github.com/facebook/react/pull/24741
if (supportsBeforeMatch && ref?.current && !isControlled && !isDisabled) {
if (state.isExpanded) {
// @ts-ignore
ref.current.hidden = undefined;
ref.current.removeAttribute('hidden');
} else {
// @ts-ignore
ref.current.hidden = 'until-found';
ref.current.setAttribute('hidden', 'until-found');
}
}
}, [isControlled, ref, props.isExpanded, state, supportsBeforeMatch, isDisabled]);
Expand All @@ -72,7 +70,7 @@ export function useDisclosure(props: AriaDisclosureProps, state: DisclosureState
'aria-expanded': state.isExpanded,
'aria-controls': contentId,
onPress: (e) => {
if (e.pointerType !== 'keyboard') {
if (!isDisabled && e.pointerType !== 'keyboard') {
state.toggle();
}
},
Expand Down
184 changes: 184 additions & 0 deletions packages/@react-aria/disclosure/test/useDisclosure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {actHook as act, renderHook} from '@react-spectrum/test-utils-internal';
import {KeyboardEvent, PressEvent} from '@react-types/shared';
import {useDisclosure} from '../src/useDisclosure';
import {useDisclosureState} from '@react-stately/disclosure';

describe('useDisclosure', () => {
let defaultProps = {};
let ref = {current: document.createElement('div')};

afterEach(() => {
jest.clearAllMocks();
});

it('should return correct aria attributes when collapsed', () => {
let {result} = renderHook(() => {
let state = useDisclosureState(defaultProps);
return useDisclosure({}, state, ref);
});

let {buttonProps, panelProps} = result.current;

expect(buttonProps['aria-expanded']).toBe(false);
expect(panelProps.hidden).toBe(true);
});

it('should return correct aria attributes when expanded', () => {
let {result} = renderHook(() => {
let state = useDisclosureState({defaultExpanded: true});
return useDisclosure({}, state, ref);
});

let {buttonProps, panelProps} = result.current;

expect(buttonProps['aria-expanded']).toBe(true);
expect(panelProps.hidden).toBe(false);
});

it('should handle expanding on press event', () => {
let {result} = renderHook(() => {
let state = useDisclosureState({});
let disclosure = useDisclosure({}, state, ref);
return {state, disclosure};
});

act(() => {
result.current.disclosure.buttonProps.onPress?.({pointerType: 'mouse'} as PressEvent);
});

expect(result.current.state.isExpanded).toBe(true);
});

it('should handle expanding on keydown event', () => {
let {result} = renderHook(() => {
let state = useDisclosureState({});
let disclosure = useDisclosure({}, state, ref);
return {state, disclosure};
});

let preventDefault = jest.fn();
let event = (e: Partial<KeyboardEvent>) => ({...e, preventDefault} as KeyboardEvent);

act(() => {
result.current.disclosure.buttonProps.onKeyDown?.(event({key: 'Enter', preventDefault}) as KeyboardEvent);
});

expect(preventDefault).toHaveBeenCalledTimes(1);

expect(result.current.state.isExpanded).toBe(true);
});

it('should not toggle when disabled', () => {
let {result} = renderHook(() => {
let state = useDisclosureState({});
let disclosure = useDisclosure({isDisabled: true}, state, ref);
return {state, disclosure};
});

act(() => {
result.current.disclosure.buttonProps.onPress?.({pointerType: 'mouse'} as PressEvent);
});

expect(result.current.state.isExpanded).toBe(false);
});

it('should set correct IDs for accessibility', () => {
let {result} = renderHook(() => {
let state = useDisclosureState({});
return useDisclosure({}, state, ref);
});

let {buttonProps, panelProps} = result.current;

expect(buttonProps['aria-controls']).toBe(panelProps.id);
expect(panelProps['aria-labelledby']).toBe(buttonProps.id);
});

it('should expand when beforematch event occurs', () => {
// Mock 'onbeforematch' support on document.body
// @ts-ignore
const originalOnBeforeMatch = document.body.onbeforematch;
Object.defineProperty(document.body, 'onbeforematch', {
value: null,
writable: true,
configurable: true
});

const ref = {current: document.createElement('div')};

const {result} = renderHook(() => {
const state = useDisclosureState({});
const disclosure = useDisclosure({}, state, ref);
return {state, disclosure};
});

expect(result.current.state.isExpanded).toBe(false);
expect(ref.current.getAttribute('hidden')).toBe('until-found');

// Simulate the 'beforematch' event
act(() => {
const event = new Event('beforematch', {bubbles: true});
ref.current.dispatchEvent(event);
});

expect(result.current.state.isExpanded).toBe(true);
expect(ref.current.hasAttribute('hidden')).toBe(false);

Object.defineProperty(document.body, 'onbeforematch', {
value: originalOnBeforeMatch,
writable: true,
configurable: true
});
});

it('should not expand when beforematch event occurs if controlled and closed', () => {
// Mock 'onbeforematch' support on document.body
// @ts-ignore
const originalOnBeforeMatch = document.body.onbeforematch;
Object.defineProperty(document.body, 'onbeforematch', {
value: null,
writable: true,
configurable: true
});

const ref = {current: document.createElement('div')};

const onExpandedChange = jest.fn();

const {result} = renderHook(() => {
const state = useDisclosureState({isExpanded: false, onExpandedChange});
const disclosure = useDisclosure({isExpanded: false}, state, ref);
return {state, disclosure};
});

expect(result.current.state.isExpanded).toBe(false);
expect(ref.current.getAttribute('hidden')).toBeNull();

// Simulate the 'beforematch' event
act(() => {
const event = new Event('beforematch', {bubbles: true});
ref.current.dispatchEvent(event);
});

expect(result.current.state.isExpanded).toBe(false);
expect(ref.current.getAttribute('hidden')).toBeNull();
expect(onExpandedChange).not.toHaveBeenCalled();

Object.defineProperty(document.body, 'onbeforematch', {
value: originalOnBeforeMatch,
writable: true,
configurable: true
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {actHook as act, renderHook} from '@react-spectrum/test-utils-internal';
import {useDisclosureGroupState} from '../src/useDisclosureGroupState';

describe('useDisclosureGroupState', () => {
it('should initialize with empty expandedKeys when not provided', () => {
const {result} = renderHook(() => useDisclosureGroupState({}));
expect(result.current.expandedKeys.size).toBe(0);
});

it('should initialize with defaultExpandedKeys when provided', () => {
const {result} = renderHook(() =>
useDisclosureGroupState({defaultExpandedKeys: ['item1']})
);
expect(result.current.expandedKeys.has('item1')).toBe(true);
expect(result.current.expandedKeys.has('item2')).toBe(false);
});

it('should initialize with multiple defaultExpandedKeys when provided, and if allowsMultipleExpanded is true', () => {
const {result} = renderHook(() =>
useDisclosureGroupState({defaultExpandedKeys: ['item1', 'item2'], allowsMultipleExpanded: true})
);
expect(result.current.expandedKeys.has('item1')).toBe(true);
expect(result.current.expandedKeys.has('item2')).toBe(true);
});

it('should allow controlled expandedKeys prop', () => {
const {result, rerender} = renderHook(
({expandedKeys}) => useDisclosureGroupState({expandedKeys}),
{initialProps: {expandedKeys: ['item1']}}
);
expect(result.current.expandedKeys.has('item1')).toBe(true);

rerender({expandedKeys: ['item2']});
expect(result.current.expandedKeys.has('item1')).toBe(false);
expect(result.current.expandedKeys.has('item2')).toBe(true);
});

it('should toggle key correctly when allowsMultipleExpanded is false', () => {
const {result} = renderHook(() => useDisclosureGroupState({}));
act(() => {
result.current.toggleKey('item1');
});
expect(result.current.expandedKeys.has('item1')).toBe(true);

act(() => {
result.current.toggleKey('item2');
});
expect(result.current.expandedKeys.has('item1')).toBe(false);
expect(result.current.expandedKeys.has('item2')).toBe(true);
});

it('should toggle key correctly when allowsMultipleExpanded is true', () => {
const {result} = renderHook(() =>
useDisclosureGroupState({allowsMultipleExpanded: true})
);
act(() => {
result.current.toggleKey('item1');
});
expect(result.current.expandedKeys.has('item1')).toBe(true);

act(() => {
result.current.toggleKey('item2');
});
expect(result.current.expandedKeys.has('item1')).toBe(true);
expect(result.current.expandedKeys.has('item2')).toBe(true);
});

it('should call onExpandedChange when expanded keys change', () => {
const onExpandedChange = jest.fn();
const {result} = renderHook(() =>
useDisclosureGroupState({onExpandedChange})
);

act(() => {
result.current.toggleKey('item1');
});
expect(onExpandedChange).toHaveBeenCalledWith(new Set(['item1']));
});

it('should not expand more than one key when allowsMultipleExpanded is false', () => {
const {result} = renderHook(() => useDisclosureGroupState({}));
act(() => {
result.current.toggleKey('item1');
result.current.toggleKey('item2');
});
expect(result.current.expandedKeys.size).toBe(1);
expect(result.current.expandedKeys.has('item2')).toBe(true);
});

it('should respect isDisabled prop', () => {
const {result} = renderHook(() => useDisclosureGroupState({isDisabled: true}));
expect(result.current.isDisabled).toBe(true);
});
});
Loading

1 comment on commit d57bd8d

@rspbot
Copy link

@rspbot rspbot commented on d57bd8d Sep 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.