Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix+tests: fix Disclosure bugs and add tests #7096

Merged
merged 4 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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