Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 14 additions & 3 deletions packages/@react-aria/grid/src/useGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@ export interface GridProps extends DOMProps, AriaLabelingProps {
/** Handler that is called when a user performs an action on the row. */
onRowAction?: (key: Key) => void,
/** Handler that is called when a user performs an action on the cell. */
onCellAction?: (key: Key) => void
onCellAction?: (key: Key) => void,
/**
* Whether pressing the escape key should clear selection in the grid or not.
*
* Most experiences should not modify this option as it eliminates a keyboard user's ability to
* easily clear selection. Only use if the escape key is being handled externally or should not
* trigger selection clearing contextually.
* @default 'clearSelection'
*/
escapeKeyBehavior?: 'clearSelection' | 'none'
}

export interface GridAria {
Expand All @@ -77,7 +86,8 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
scrollRef,
getRowText,
onRowAction,
onCellAction
onCellAction,
escapeKeyBehavior = 'clearSelection'
} = props;
let {selectionManager: manager} = state;

Expand Down Expand Up @@ -106,7 +116,8 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
keyboardDelegate: delegate,
isVirtualized,
scrollRef,
disallowTypeAhead
disallowTypeAhead,
escapeKeyBehavior
});

let id = useId(props.id);
Expand Down
17 changes: 14 additions & 3 deletions packages/@react-aria/gridlist/src/useGridList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,16 @@ export interface AriaGridListProps<T> extends GridListProps<T>, DOMProps, AriaLa
* via the left/right arrow keys or the tab key.
* @default 'arrow'
*/
keyboardNavigationBehavior?: 'arrow' | 'tab'
keyboardNavigationBehavior?: 'arrow' | 'tab',
/**
* Whether pressing the escape key should clear selection in the grid list or not.
*
* Most experiences should not modify this option as it eliminates a keyboard user's ability to
* easily clear selection. Only use if the escape key is being handled externally or should not
* trigger selection clearing contextually.
* @default 'clearSelection'
*/
escapeKeyBehavior?: 'clearSelection' | 'none'
}

export interface AriaGridListOptions<T> extends Omit<AriaGridListProps<T>, 'children'> {
Expand Down Expand Up @@ -105,7 +114,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
onAction,
disallowTypeAhead,
linkBehavior = 'action',
keyboardNavigationBehavior = 'arrow'
keyboardNavigationBehavior = 'arrow',
escapeKeyBehavior = 'clearSelection'
} = props;

if (!props['aria-label'] && !props['aria-labelledby']) {
Expand All @@ -124,7 +134,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
shouldFocusWrap: props.shouldFocusWrap,
linkBehavior,
disallowTypeAhead,
autoFocus: props.autoFocus
autoFocus: props.autoFocus,
escapeKeyBehavior
});

let id = useId(props.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface AriaSelectableCollectionOptions {
* @default false
*/
disallowSelectAll?: boolean,
/**
* Whether pressing the Escape should clear selection in the collection or not.
* @default 'clearSelection'
*/
escapeKeyBehavior?: 'clearSelection' | 'none',
/**
* Whether selection should occur automatically on focus.
* @default false
Expand Down Expand Up @@ -108,6 +113,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
shouldFocusWrap = false,
disallowEmptySelection = false,
disallowSelectAll = false,
escapeKeyBehavior = 'clearSelection',
selectOnFocus = manager.selectionBehavior === 'replace',
disallowTypeAhead = false,
shouldUseVirtualFocus,
Expand Down Expand Up @@ -279,7 +285,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
}
break;
case 'Escape':
if (!disallowEmptySelection && manager.selectedKeys.size !== 0) {
if (escapeKeyBehavior === 'clearSelection' && !disallowEmptySelection && manager.selectedKeys.size !== 0) {
e.stopPropagation();
e.preventDefault();
manager.clearSelection();
Expand Down
18 changes: 18 additions & 0 deletions packages/@react-spectrum/list/test/ListView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,24 @@ describe('ListView', function () {
expect(announce).toHaveBeenCalledTimes(3);
});

it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async function () {
let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', escapeKeyBehavior: 'none'});

let rows = tree.getAllByRole('row');
await user.click(within(rows[1]).getByRole('checkbox'));
checkSelection(onSelectionChange, ['bar']);

onSelectionChange.mockClear();
await user.click(within(rows[2]).getByRole('checkbox'));
checkSelection(onSelectionChange, ['bar', 'baz']);

onSelectionChange.mockClear();
await user.keyboard('{Escape}');
expect(onSelectionChange).not.toHaveBeenCalled();
expect(rows[1]).toHaveAttribute('aria-selected', 'true');
expect(rows[2]).toHaveAttribute('aria-selected', 'true');
});

it('should support range selection', async function () {
let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'});

Expand Down
24 changes: 24 additions & 0 deletions packages/@react-spectrum/listbox/test/ListBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,30 @@ describe('ListBox', function () {

expect(onSelectionChange).toBeCalledTimes(0);
});

it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async function () {
let user = userEvent.setup({delay: null, pointerMap});
let tree = renderComponent({onSelectionChange, selectionMode: 'multiple', escapeKeyBehavior: 'none'});
let listbox = tree.getByRole('listbox');

let options = within(listbox).getAllByRole('option');
let firstItem = options[3];
await user.click(firstItem);
expect(firstItem).toHaveAttribute('aria-selected', 'true');

let secondItem = options[1];
await user.click(secondItem);
expect(secondItem).toHaveAttribute('aria-selected', 'true');

expect(onSelectionChange).toBeCalledTimes(2);
expect(onSelectionChange.mock.calls[0][0].has('Blah')).toBeTruthy();
expect(onSelectionChange.mock.calls[1][0].has('Bar')).toBeTruthy();

await user.keyboard('{Escape}');
expect(onSelectionChange).toBeCalledTimes(2);
expect(onSelectionChange.mock.calls[0][0].has('Blah')).toBeTruthy();
expect(onSelectionChange.mock.calls[1][0].has('Bar')).toBeTruthy();
});
});

describe('supports no selection', function () {
Expand Down
24 changes: 24 additions & 0 deletions packages/@react-spectrum/menu/test/MenuTrigger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,30 @@ describe('MenuTrigger', function () {
expect(onOpenChange).toBeCalledTimes(1);
});

it.each`
Name | Component | props
${'MenuTrigger'} | ${MenuTrigger} | ${{onOpenChange}}
`('$Name should prevent Esc from clearing selection and close the menu if escapeKeyBehavior is "none"', async function ({Component, props}) {
tree = renderComponent(Component, props, {selectionMode: 'multiple', escapeKeyBehavior: 'none', onSelectionChange});
let menuTester = testUtilUser.createTester('Menu', {root: tree.container, interactionType: 'keyboard'});
expect(onOpenChange).toBeCalledTimes(0);
await menuTester.open();

expect(onOpenChange).toBeCalledTimes(1);
expect(onSelectionChange).toBeCalledTimes(0);

await menuTester.selectOption({option: 'Foo', menuSelectionMode: 'multiple', keyboardActivation: 'Space'});
expect(onSelectionChange).toBeCalledTimes(1);
expect(onSelectionChange.mock.calls[0][0].has('Foo')).toBeTruthy();
await menuTester.selectOption({option: 'Bar', menuSelectionMode: 'multiple', keyboardActivation: 'Space'});
expect(onSelectionChange).toBeCalledTimes(2);
expect(onSelectionChange.mock.calls[1][0].has('Bar')).toBeTruthy();

await menuTester.close();
expect(menuTester.menu).not.toBeInTheDocument();
expect(onOpenChange).toBeCalledTimes(2);
});

it.each`
Name | Component | props | menuProps
${'MenuTrigger multiple'} | ${MenuTrigger} | ${{closeOnSelect: true}} | ${{selectionMode: 'multiple', onClose}}
Expand Down
24 changes: 22 additions & 2 deletions packages/@react-spectrum/table/test/Table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,9 @@ function pointerEvent(type, opts) {
}

export let tableTests = () => {
// Temporarily disabling these tests in React 16 because they run into a memory limit and crash.
// Temporarily disabling these tests in React 16/17 because they run into a memory limit and crash.
// TODO: investigate.
if (parseInt(React.version, 10) <= 16) {
if (parseInt(React.version, 10) <= 17) {
return;
}

Expand Down Expand Up @@ -2217,6 +2217,26 @@ export let tableTests = () => {
checkSelectAll(tree, 'indeterminate');
});

it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async function () {
let onSelectionChange = jest.fn();
let tree = renderTable({onSelectionChange, selectionMode: 'multiple', escapeKeyBehavior: 'none'});
let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')});
tableTester.setInteractionType('keyboard');


await tableTester.toggleRowSelection({row: 0});
expect(tableTester.selectedRows).toHaveLength(1);
expect(onSelectionChange).toHaveBeenCalledTimes(1);

await tableTester.toggleRowSelection({row: 1});
expect(tableTester.selectedRows).toHaveLength(2);
expect(onSelectionChange).toHaveBeenCalledTimes(2);

await user.keyboard('{Escape}');
expect(tableTester.selectedRows).toHaveLength(2);
expect(onSelectionChange).toHaveBeenCalledTimes(2);
});

it('should not allow selection of a disabled row via checkbox click', async function () {
let onSelectionChange = jest.fn();
let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']});
Expand Down
21 changes: 21 additions & 0 deletions packages/@react-spectrum/tree/test/TreeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,27 @@ describe('Tree', () => {
expect(treeTester.selectedRows[0]).toBe(row1);
});

it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => {
let {getByRole} = render(<StaticTree treeProps={{selectionMode: 'multiple', escapeKeyBehavior: 'none'}} />);
let treeTester = testUtilUser.createTester('Tree', {user, root: getByRole('treegrid')});
let rows = treeTester.rows;
let row1 = rows[1];
await treeTester.toggleRowSelection({row: row1});
expect(onSelectionChange).toHaveBeenCalledTimes(1);
expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Projects']));
expect(treeTester.selectedRows).toHaveLength(1);

let row2 = rows[2];
await treeTester.toggleRowSelection({row: row2});
expect(onSelectionChange).toHaveBeenCalledTimes(2);
expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Projects', 'Projects-1']));
expect(treeTester.selectedRows).toHaveLength(2);

await user.keyboard('{Escape}');
expect(onSelectionChange).toHaveBeenCalledTimes(2);
expect(treeTester.selectedRows).toHaveLength(2);
});

it('should render a chevron for an expandable row marked with hasChildItems', () => {
let {getAllByRole} = render(
<TreeView aria-label="test tree">
Expand Down
12 changes: 11 additions & 1 deletion packages/@react-types/listbox/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ export interface ListBoxProps<T> extends CollectionBase<T>, MultipleSelection, F
shouldFocusWrap?: boolean
}

interface AriaListBoxPropsBase<T> extends ListBoxProps<T>, DOMProps, AriaLabelingProps {}
interface AriaListBoxPropsBase<T> extends ListBoxProps<T>, DOMProps, AriaLabelingProps {
/**
* Whether pressing the escape key should clear selection in the listbox or not.
*
* Most experiences should not modify this option as it eliminates a keyboard user's ability to
* easily clear selection. Only use if the escape key is being handled externally or should not
* trigger selection clearing contextually.
* @default 'clearSelection'
*/
escapeKeyBehavior?: 'clearSelection' | 'none'
}
export interface AriaListBoxProps<T> extends AriaListBoxPropsBase<T> {
/**
* An optional visual label for the listbox.
Expand Down
12 changes: 11 additions & 1 deletion packages/@react-types/menu/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,17 @@ export interface MenuProps<T> extends CollectionBase<T>, MultipleSelection {
onClose?: () => void
}

export interface AriaMenuProps<T> extends MenuProps<T>, DOMProps, AriaLabelingProps {}
export interface AriaMenuProps<T> extends MenuProps<T>, DOMProps, AriaLabelingProps {
/**
* Whether pressing the escape key should clear selection in the menu or not.
*
* Most experiences should not modify this option as it eliminates a keyboard user's ability to
* easily clear selection. Only use if the escape key is being handled externally or should not
* trigger selection clearing contextually.
* @default 'clearSelection'
*/
escapeKeyBehavior?: 'clearSelection' | 'none'
}
export interface SpectrumMenuProps<T> extends AriaMenuProps<T>, StyleProps {}

export interface SpectrumActionMenuProps<T> extends CollectionBase<T>, Omit<SpectrumMenuTriggerProps, 'children'>, StyleProps, DOMProps, AriaLabelingProps {
Expand Down
11 changes: 10 additions & 1 deletion packages/@react-types/table/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@ export interface TableProps<T> extends MultipleSelection, Sortable {
/** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */
children: [ReactElement<TableHeaderProps<T>>, ReactElement<TableBodyProps<T>>],
/** A list of row keys to disable. */
disabledKeys?: Iterable<Key>
disabledKeys?: Iterable<Key>,
/**
* Whether pressing the escape key should clear selection in the table or not.
*
* Most experiences should not modify this option as it eliminates a keyboard user's ability to
* easily clear selection. Only use if the escape key is being handled externally or should not
* trigger selection clearing contextually.
* @default 'clearSelection'
*/
escapeKeyBehavior?: 'clearSelection' | 'none'
}

/**
Expand Down
Loading