diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 0ebab0ecab7..6e848fb51f0 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -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 { @@ -77,7 +86,8 @@ export function useGrid(props: GridProps, state: GridState(props: GridProps, state: GridState extends GridListProps, 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 extends Omit, 'children'> { @@ -105,7 +114,8 @@ export function useGridList(props: AriaGridListOptions, state: ListState(props: AriaGridListOptions, state: ListState { - // 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; } @@ -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']}); diff --git a/packages/@react-spectrum/tree/test/TreeView.test.tsx b/packages/@react-spectrum/tree/test/TreeView.test.tsx index 5d2fee7c61d..8f411a81163 100644 --- a/packages/@react-spectrum/tree/test/TreeView.test.tsx +++ b/packages/@react-spectrum/tree/test/TreeView.test.tsx @@ -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(); + 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( diff --git a/packages/@react-types/listbox/src/index.d.ts b/packages/@react-types/listbox/src/index.d.ts index ed887021781..082b44669f8 100644 --- a/packages/@react-types/listbox/src/index.d.ts +++ b/packages/@react-types/listbox/src/index.d.ts @@ -20,7 +20,17 @@ export interface ListBoxProps extends CollectionBase, MultipleSelection, F shouldFocusWrap?: boolean } -interface AriaListBoxPropsBase extends ListBoxProps, DOMProps, AriaLabelingProps {} +interface AriaListBoxPropsBase extends ListBoxProps, 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 extends AriaListBoxPropsBase { /** * An optional visual label for the listbox. diff --git a/packages/@react-types/menu/src/index.d.ts b/packages/@react-types/menu/src/index.d.ts index d2564749978..a3b4ae883eb 100644 --- a/packages/@react-types/menu/src/index.d.ts +++ b/packages/@react-types/menu/src/index.d.ts @@ -62,7 +62,17 @@ export interface MenuProps extends CollectionBase, MultipleSelection { onClose?: () => void } -export interface AriaMenuProps extends MenuProps, DOMProps, AriaLabelingProps {} +export interface AriaMenuProps extends MenuProps, 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 extends AriaMenuProps, StyleProps {} export interface SpectrumActionMenuProps extends CollectionBase, Omit, StyleProps, DOMProps, AriaLabelingProps { diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 003caa9f291..656b82dd185 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -29,7 +29,16 @@ export interface TableProps extends MultipleSelection, Sortable { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ children: [ReactElement>, ReactElement>], /** A list of row keys to disable. */ - disabledKeys?: Iterable + disabledKeys?: Iterable, + /** + * 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' } /** diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 7489a98db93..4c299fe80a0 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -22,7 +22,8 @@ export default { title: 'React Aria Components', args: { onAction: action('onAction'), - selectionMode: 'multiple' + selectionMode: 'multiple', + escapeKeyBehavior: 'clearSelection' }, argTypes: { onAction: { @@ -38,6 +39,10 @@ export default { selectionMode: { control: 'radio', options: ['none', 'single', 'multiple'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] } } }; @@ -109,8 +114,6 @@ function AutocompleteWrapper(props) { export const AutocompleteExample = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; - return (
@@ -119,7 +122,7 @@ export const AutocompleteExample = { Please select an option below. - +
); @@ -129,7 +132,6 @@ export const AutocompleteExample = { export const AutocompleteSearchfield = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; return (
@@ -138,7 +140,7 @@ export const AutocompleteSearchfield = { Please select an option below. - +
); @@ -288,8 +290,6 @@ let dynamicRenderFuncSections = (item: ItemNode) => { export const AutocompleteMenuDynamic = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; - return ( <> @@ -300,7 +300,7 @@ export const AutocompleteMenuDynamic = { Please select an option below. - + {item => dynamicRenderFuncSections(item)} @@ -314,7 +314,6 @@ export const AutocompleteMenuDynamic = { export const AutocompleteOnActionOnMenuItems = { render: (args) => { - let {onSelectionChange, selectionMode} = args; return (
@@ -323,7 +322,7 @@ export const AutocompleteOnActionOnMenuItems = { Please select an option below. - + Foo Bar Baz @@ -344,7 +343,6 @@ let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, export const AutocompleteDisabledKeys = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; return (
@@ -353,8 +351,8 @@ export const AutocompleteDisabledKeys = { Please select an option below. - - {item => {item.name}} + + {(item: AutocompleteItem) => {item.name}}
@@ -386,7 +384,7 @@ const AsyncExample = (args) => { }; } }); - let {onSelectionChange, selectionMode, includeLoadState} = args; + let {onSelectionChange, selectionMode, includeLoadState, escapeKeyBehavior} = args; let renderEmptyState; if (includeLoadState) { renderEmptyState = list.isLoading ? () => 'Loading' : () => 'No results found.'; @@ -401,6 +399,7 @@ const AsyncExample = (args) => { Please select an option below. + escapeKeyBehavior={escapeKeyBehavior} renderEmptyState={renderEmptyState} items={includeLoadState && list.isLoading ? [] : list.items} className={styles.menu} @@ -428,7 +427,7 @@ const CaseSensitiveFilter = (args) => { sensitivity: 'case' }); let defaultFilter = (itemText, input) => contains(itemText, input); - let {onAction, onSelectionChange, selectionMode} = args; + return (
@@ -437,8 +436,8 @@ const CaseSensitiveFilter = (args) => { Please select an option below. - - {item => {item.name}} + + {(item: AutocompleteItem) => {item.name}}
@@ -454,35 +453,51 @@ export const AutocompleteCaseSensitive = { export const AutocompleteWithListbox = { render: (args) => { - let {onSelectionChange, selectionMode} = args; return ( - -
- - - - Please select an option below. - - - -
Section 1
- Foo - Bar - Baz - Google -
- - - Copy - Paste - Cut - -
-
-
+ + + + {() => ( + +
+ + + + Please select an option below. + + + +
Section 1
+ Foo + Bar + Baz + Google +
+ + + Copy + Paste + Cut + +
+
+
+ )} +
+
); }, - name: 'Autocomplete with ListBox' + name: 'Autocomplete with ListBox + Popover' }; function VirtualizedListBox(props) { @@ -495,15 +510,16 @@ function VirtualizedListBox(props) { initialItems: items }); - let {onSelectionChange, selectionMode} = props; + let {onSelectionChange, selectionMode, escapeKeyBehavior} = props; return ( {item => {item.name}} @@ -514,21 +530,37 @@ function VirtualizedListBox(props) { export const AutocompleteWithVirtualizedListbox = { render: (args) => { - let {onSelectionChange, selectionMode} = args; return ( - -
- - - - Please select an option below. - - -
-
+ + + + {() => ( + +
+ + + + Please select an option below. + + +
+
+ )} +
+
); }, - name: 'Autocomplete with ListBox, virtualized' + name: 'Autocomplete with ListBox + Popover, virtualized' }; let lotsOfSections: any[] = []; @@ -751,9 +783,11 @@ export function AutocompleteWithExtraButtons() { ); } +// TODO: note that Space is used to select an item in a multiselect menu but that is also reserved for the +// autocomplete input field. Should we add logic to allow Space to select menu items when focus is in the Menu +// or is that a rare/unlikely use case for menus in general? export const AutocompleteMenuInPopoverDialogTrigger = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; return (
diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index ee2685554df..ab868c925c3 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -65,7 +65,8 @@ const MyGridListItem = (props: GridListItemProps) => { GridListExample.story = { args: { - layout: 'stack' + layout: 'stack', + escapeKeyBehavior: 'clearSelection' }, argTypes: { layout: { @@ -83,6 +84,10 @@ GridListExample.story = { selectionBehavior: { control: 'radio', options: ['toggle', 'replace'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] } } }; @@ -158,7 +163,7 @@ export function VirtualizedGridListGrid() { } return ( - Actions - 1,3 + 1,3 Tag 1 diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index b4008633204..d815ab2ba00 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -35,7 +35,8 @@ ListBoxExample.story = { args: { selectionMode: 'none', selectionBehavior: 'toggle', - shouldFocusOnHover: false + shouldFocusOnHover: false, + escapeKeyBehavior: 'clearSelection' }, argTypes: { selectionMode: { @@ -45,6 +46,10 @@ ListBoxExample.story = { selectionBehavior: { control: 'radio', options: ['toggle', 'replace'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] } }, parameters: { @@ -363,7 +368,7 @@ function VirtualizedListBoxGridExample({minSize = 80, maxSize = 100, preserveAsp return (
- - ( ); -export const TableExample = () => { +const TableExample = (args) => { let list = useListData({ initialItems: [ {id: 1, name: 'Games', date: '6/7/2020', type: 'File folder'}, @@ -112,10 +112,11 @@ export const TableExample = () => { }); return ( - - + +
- Name + + Name Type Date Modified Actions @@ -123,6 +124,7 @@ export const TableExample = () => { {item => ( + {item.name} {item.type} {item.date} @@ -175,6 +177,29 @@ export const TableExample = () => { ); }; +export const TableExampleStory = { + render: TableExample, + args: { + selectionMode: 'none', + selectionBehavior: 'toggle', + escapeKeyBehavior: 'clearSelection' + }, + argTypes: { + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] + } + } +}; + let columns = [ {name: 'Name', id: 'name', isRowHeader: true}, {name: 'Type', id: 'type'}, diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 4bf2c53a1b0..de7df787d95 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -147,7 +147,8 @@ export const TreeExampleStatic = { args: { selectionMode: 'none', selectionBehavior: 'toggle', - disabledBehavior: 'selection' + disabledBehavior: 'selection', + disallowClearAll: false }, argTypes: { selectionMode: { diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 7dc1f5476dc..eaa2c274347 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -621,6 +621,86 @@ describe('Autocomplete', () => { let options = within(menu).getAllByRole('menuitem'); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); }); + + it('should close the Dialog on the second press of Escape if the inner ListBox has escapeKeyBehavior: "none" ', async () => { + const DialogExample = (props) => { + let {contains} = useFilter({sensitivity: 'base'}); + + return ( + + + + + + + + + + + + + + + + ); + }; + + let {getByRole, getAllByRole, rerender, queryAllByRole} = render(); + let button = getByRole('button'); + await user.tab(); + expect(document.activeElement).toBe(button); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + let options = getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + + let input = getByRole('searchbox'); + expect(document.activeElement).toBe(input); + await user.keyboard('I'); + expect(input).toHaveValue('I'); + + await user.keyboard('{Escape}'); + expect(input).toHaveValue(''); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(0); + + // Test without escapeKeyBehavior, 2nd Escape should clear selection instead of closing the dialog + rerender(); + button = getByRole('button'); + await user.click(button); + act(() => jest.runAllTimers()); + + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + options = getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + + input = getByRole('searchbox'); + expect(document.activeElement).toBe(input); + await user.keyboard('I'); + expect(input).toHaveValue('I'); + + await user.keyboard('{Escape}'); + expect(input).toHaveValue(''); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + options = getAllByRole('option'); + expect(options[0]).not.toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(0); + }); }); AriaAutocompleteTests({ diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 9a4d106fe3c..70ee7803246 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -248,6 +248,23 @@ describe('GridList', () => { expect(within(row).getByRole('checkbox')).not.toBeChecked(); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getByRole} = renderGridList({selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); + + let row = gridListTester.rows[0]; + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + + await gridListTester.toggleRowSelection({row: 0}); + expect(gridListTester.selectedRows).toHaveLength(1); + + await gridListTester.toggleRowSelection({row: 1}); + expect(gridListTester.selectedRows).toHaveLength(2); + + await user.keyboard('{Escape}'); + expect(gridListTester.selectedRows).toHaveLength(2); + }); + it('should support disabled state', () => { let {getAllByRole} = renderGridList({selectionMode: 'multiple', disabledKeys: ['cat']}, {className: ({isDisabled}) => isDisabled ? 'disabled' : ''}); let row = getAllByRole('row')[0]; diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 869f2d841d9..88070aef887 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -821,6 +821,21 @@ describe('ListBox', () => { expect(options.map(r => r.textContent)).toEqual(['Item 7', 'Item 8', 'Item 9', 'Item 10', 'Item 11', 'Item 12', 'Item 13', 'Item 14', 'Item 49']); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getByRole} = renderListbox({selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + + let listboxTester = testUtilUser.createTester('ListBox', {root: getByRole('listbox')}); + let option = listboxTester.options()[0]; + expect(option).not.toHaveAttribute('aria-selected', 'true'); + expect(option).not.toHaveClass('selected'); + + await listboxTester.toggleOptionSelection({option}); + expect(option).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{Escape}'); + expect(option).toHaveAttribute('aria-selected', 'true'); + }); + describe('drag and drop', () => { it('should support draggable items', () => { let {getAllByRole} = render(); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index 79b84f8f525..8f10e304fbc 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -386,6 +386,19 @@ describe('Menu', () => { } }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getAllByRole} = renderMenu({selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + let menuitem = getAllByRole('menuitemcheckbox')[0]; + + expect(menuitem).not.toHaveAttribute('aria-checked', 'true'); + + await user.click(menuitem); + expect(menuitem).toHaveAttribute('aria-checked', 'true'); + + await user.keyboard('{Escape}'); + expect(menuitem).toHaveAttribute('aria-checked', 'true'); + }); + it('should support disabled state', () => { let {getAllByRole} = renderMenu({disabledKeys: ['cat']}, {className: ({isDisabled}) => isDisabled ? 'disabled' : ''}); let menuitem = getAllByRole('menuitem')[0]; diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 3d72ef98283..1c1648c396e 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -335,6 +335,28 @@ describe('Table', () => { } }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderTable({ + tableProps: {selectionMode: 'multiple', escapeKeyBehavior: 'none', onSelectionChange} + }); + + let checkbox1 = getAllByRole('checkbox')[1]; + await user.click(checkbox1); + expect(checkbox1).toBeChecked(); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); + + let checkbox2 = getAllByRole('checkbox')[2]; + await user.click(checkbox2); + expect(checkbox2).toBeChecked(); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['1', '2'])); + + await user.keyboard('{Escape}'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(checkbox1).toBeChecked(); + expect(checkbox2).toBeChecked(); + }); + it('should not render checkboxes for selection with selectionBehavior=replace', async () => { let {getAllByRole} = renderTable({ tableProps: { diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index f22e70f8ff8..6d74c669547 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -629,6 +629,24 @@ describe('Tree', () => { expect(onSelectionChange).toHaveBeenCalledTimes(0); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getAllByRole} = render(); + + let rows = getAllByRole('row'); + await user.click(rows[0]); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Photos'])); + + await user.click(rows[1]); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Photos', 'projects'])); + + await user.keyboard('{Escape}'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(rows[0]).toHaveAttribute('data-selected'); + expect(rows[1]).toHaveAttribute('data-selected'); + }); + it('should support onScroll', () => { let onScroll = jest.fn(); let {getByRole} = render();