diff --git a/packages/@react-aria/test-utils/package.json b/packages/@react-aria/test-utils/package.json index 83ca24c5a02..30eec9a5113 100644 --- a/packages/@react-aria/test-utils/package.json +++ b/packages/@react-aria/test-utils/package.json @@ -22,6 +22,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/utils": "^3.28.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { diff --git a/packages/@react-aria/test-utils/src/events.ts b/packages/@react-aria/test-utils/src/events.ts index 0d09ca80217..4760fc8e6d7 100644 --- a/packages/@react-aria/test-utils/src/events.ts +++ b/packages/@react-aria/test-utils/src/events.ts @@ -11,9 +11,17 @@ */ import {act, fireEvent} from '@testing-library/react'; +import {isMac} from '@react-aria/utils'; import {UserOpts} from './types'; export const DEFAULT_LONG_PRESS_TIME = 500; +export function getAltKey(): 'Alt' | 'ControlLeft' { + return isMac() ? 'Alt' : 'ControlLeft'; +} + +export function getMetaKey(): 'MetaLeft' | 'ControlLeft' { + return isMac() ? 'MetaLeft' : 'ControlLeft'; +} /** * Simulates a "long press" event on a element. @@ -58,9 +66,10 @@ export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer } // Docs cannot handle the types that userEvent actually declares, so hopefully this sub set is okay -export async function pressElement(user: {click: (element: Element) => Promise, keyboard: (keys: string) => Promise, pointer: (opts: {target: Element, keys: string}) => Promise}, element: HTMLElement, interactionType: UserOpts['interactionType']): Promise { +export async function pressElement(user: {click: (element: Element) => Promise, keyboard: (keys: string) => Promise, pointer: (opts: {target: Element, keys: string, coords?: any}) => Promise}, element: HTMLElement, interactionType: UserOpts['interactionType']): Promise { if (interactionType === 'mouse') { - await user.click(element); + // Add coords with pressure so this isn't detected as a virtual click + await user.pointer({target: element, keys: '[MouseLeft]', coords: {pressure: .5}}); } else if (interactionType === 'keyboard') { // TODO: For the keyboard flow, I wonder if it would be reasonable to just do fireEvent directly on the obtained row node or if we should // stick to simulting an actual user's keyboard operations as closely as possible diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index 8be873475fd..f0f6b0d4533 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -11,8 +11,8 @@ */ import {act, within} from '@testing-library/react'; +import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types'; -import {pressElement, triggerLongPress} from './events'; interface GridListToggleRowOpts extends ToggleGridRowOpts {} interface GridListRowActionOpts extends GridRowActionOpts {} @@ -57,20 +57,21 @@ export class GridListTester { } // TODO: RTL - private async keyboardNavigateToRow(opts: {row: HTMLElement}) { - let {row} = opts; + private async keyboardNavigateToRow(opts: {row: HTMLElement, useAltKey?: boolean}) { + let {row, useAltKey} = opts; + let altKey = getAltKey(); let rows = this.rows; let targetIndex = rows.indexOf(row); if (targetIndex === -1) { throw new Error('Option provided is not in the gridlist'); } - if (document.activeElement !== this._gridlist || !this._gridlist.contains(document.activeElement)) { + if (document.activeElement !== this._gridlist && !this._gridlist.contains(document.activeElement)) { act(() => this._gridlist.focus()); } if (document.activeElement === this._gridlist) { - await this.user.keyboard('[ArrowDown]'); + await this.user.keyboard(`${useAltKey ? `[${altKey}>]` : ''}[ArrowDown]${useAltKey ? `[/${altKey}]` : ''}`); } else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); @@ -82,22 +83,34 @@ export class GridListTester { } let direction = targetIndex > currIndex ? 'down' : 'up'; + if (useAltKey) { + await this.user.keyboard(`[${altKey}>]`); + } for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) { await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`); } + if (useAltKey) { + await this.user.keyboard(`[/${altKey}]`); + } }; /** * Toggles the selection for the specified gridlist row. Defaults to using the interaction type set on the gridlist tester. + * Note that this will endevor to always add/remove JUST the provided row to the set of selected rows. */ async toggleRowSelection(opts: GridListToggleRowOpts): Promise { let { row, needsLongPress, checkboxSelection = true, - interactionType = this._interactionType + interactionType = this._interactionType, + // TODO: perhaps this should just be shouldUseModifierKeys? + selectionBehavior = 'toggle' } = opts; + let altKey = getAltKey(); + let metaKey = getMetaKey(); + if (typeof row === 'string' || typeof row === 'number') { row = this.findRow({rowIndexOrText: row}); } @@ -116,9 +129,15 @@ export class GridListTester { // this would be better than the check to do nothing in events.ts // also, it'd be good to be able to trigger selection on the row instead of having to go to the checkbox directly - if (interactionType === 'keyboard' && !checkboxSelection) { - await this.keyboardNavigateToRow({row}); - await this.user.keyboard('{Space}'); + if (interactionType === 'keyboard' && (!checkboxSelection || !rowCheckbox)) { + await this.keyboardNavigateToRow({row, useAltKey: selectionBehavior === 'replace'}); + if (selectionBehavior === 'replace') { + await this.user.keyboard(`[${altKey}>]`); + } + await this.user.keyboard('[Space]'); + if (selectionBehavior === 'replace') { + await this.user.keyboard(`[/${altKey}]`); + } return; } if (rowCheckbox && checkboxSelection) { @@ -132,9 +151,14 @@ export class GridListTester { // Note that long press interactions with rows is strictly touch only for grid rows await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}}); - } else { - await pressElement(this.user, cell, interactionType); + if (selectionBehavior === 'replace' && interactionType !== 'touch') { + await this.user.keyboard(`[${metaKey}>]`); + } + await pressElement(this.user, row, interactionType); + if (selectionBehavior === 'replace' && interactionType !== 'touch') { + await this.user.keyboard(`[/${metaKey}]`); + } } } } @@ -166,7 +190,7 @@ export class GridListTester { return; } - await this.keyboardNavigateToRow({row}); + await this.keyboardNavigateToRow({row, useAltKey: true}); await this.user.keyboard('[Enter]'); } else { await pressElement(this.user, row, interactionType); diff --git a/packages/@react-aria/test-utils/src/listbox.ts b/packages/@react-aria/test-utils/src/listbox.ts index 152d7426665..7c0093d4032 100644 --- a/packages/@react-aria/test-utils/src/listbox.ts +++ b/packages/@react-aria/test-utils/src/listbox.ts @@ -11,8 +11,8 @@ */ import {act, within} from '@testing-library/react'; +import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {ListBoxTesterOpts, UserOpts} from './types'; -import {pressElement, triggerLongPress} from './events'; interface ListBoxToggleOptionOpts { /** @@ -31,7 +31,16 @@ interface ListBoxToggleOptionOpts { /** * Whether the option needs to be long pressed to be selected. Depends on the listbox's implementation. */ - needsLongPress?: boolean + needsLongPress?: boolean, + /** + * Whether the listbox has a selectionBehavior of "toggle" or "replace" (aka highlight selection). This affects the user operations + * required to toggle option selection by adding modifier keys during user actions, useful when performing multi-option selection in a "selectionBehavior: 'replace'" listbox. + * If you would like to still simulate user actions (aka press) without these modifiers keys for a "selectionBehavior: replace" listbox, simply omit this option. + * See the [RAC Listbox docs](https://react-spectrum.adobe.com/react-aria/ListBox.html#selection-behavior) for more info on this behavior. + * + * @default 'toggle' + */ + selectionBehavior?: 'toggle' | 'replace' } interface ListBoxOptionActionOpts extends Omit { @@ -85,44 +94,52 @@ export class ListBoxTester { // TODO: this is basically the same as menu except for the error message, refactor later so that they share // TODO: this also doesn't support grid layout yet - private async keyboardNavigateToOption(opts: {option: HTMLElement}) { - let {option} = opts; + private async keyboardNavigateToOption(opts: {option: HTMLElement, useAltKey?: boolean}) { + let {option, useAltKey} = opts; + let altKey = getAltKey(); let options = this.options(); let targetIndex = options.indexOf(option); if (targetIndex === -1) { throw new Error('Option provided is not in the listbox'); } - if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) { + if (document.activeElement !== this._listbox && !this._listbox.contains(document.activeElement)) { act(() => this._listbox.focus()); - } - - await this.user.keyboard('[ArrowDown]'); - - // TODO: not sure about doing same while loop that exists in other implementations of keyboardNavigateToOption, - // feels like it could break easily - if (document.activeElement?.getAttribute('role') !== 'option') { - await act(async () => { - option.focus(); - }); + await this.user.keyboard(`${useAltKey ? `[${altKey}>]` : ''}[ArrowDown]${useAltKey ? `[/${altKey}]` : ''}`); } let currIndex = options.indexOf(document.activeElement as HTMLElement); if (currIndex === -1) { throw new Error('ActiveElement is not in the listbox'); } - let direction = targetIndex > currIndex ? 'down' : 'up'; + let direction = targetIndex > currIndex ? 'down' : 'up'; + if (useAltKey) { + await this.user.keyboard(`[${altKey}>]`); + } for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) { await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`); } + if (useAltKey) { + await this.user.keyboard(`[/${altKey}]`); + } }; /** * Toggles the selection for the specified listbox option. Defaults to using the interaction type set on the listbox tester. */ async toggleOptionSelection(opts: ListBoxToggleOptionOpts): Promise { - let {option, needsLongPress, keyboardActivation = 'Enter', interactionType = this._interactionType} = opts; + let { + option, + needsLongPress, + keyboardActivation = 'Enter', + interactionType = this._interactionType, + // TODO: perhaps this should just be shouldUseModifierKeys? + selectionBehavior = 'toggle' + } = opts; + + let altKey = getAltKey(); + let metaKey = getMetaKey(); if (typeof option === 'string' || typeof option === 'number') { option = this.findOption({optionIndexOrText: option}); @@ -137,8 +154,14 @@ export class ListBoxTester { return; } - await this.keyboardNavigateToOption({option}); + await this.keyboardNavigateToOption({option, useAltKey: selectionBehavior === 'replace'}); + if (selectionBehavior === 'replace') { + await this.user.keyboard(`[${altKey}>]`); + } await this.user.keyboard(`[${keyboardActivation}]`); + if (selectionBehavior === 'replace') { + await this.user.keyboard(`[/${altKey}]`); + } } else { if (needsLongPress && interactionType === 'touch') { if (this._advanceTimer == null) { @@ -147,7 +170,13 @@ export class ListBoxTester { await triggerLongPress({element: option, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}}); } else { + if (selectionBehavior === 'replace' && interactionType !== 'touch') { + await this.user.keyboard(`[${metaKey}>]`); + } await pressElement(this.user, option, interactionType); + if (selectionBehavior === 'replace' && interactionType !== 'touch') { + await this.user.keyboard(`[/${metaKey}]`); + } } } } @@ -177,7 +206,7 @@ export class ListBoxTester { return; } - await this.keyboardNavigateToOption({option}); + await this.keyboardNavigateToOption({option, useAltKey: true}); await this.user.keyboard('[Enter]'); } else { await pressElement(this.user, option, interactionType); diff --git a/packages/@react-aria/test-utils/src/menu.ts b/packages/@react-aria/test-utils/src/menu.ts index 232f7b7685c..87af5c11fd0 100644 --- a/packages/@react-aria/test-utils/src/menu.ts +++ b/packages/@react-aria/test-utils/src/menu.ts @@ -215,7 +215,7 @@ export class MenuTester { return; } - if (document.activeElement !== menu || !menu.contains(document.activeElement)) { + if (document.activeElement !== menu && !menu.contains(document.activeElement)) { act(() => menu.focus()); } diff --git a/packages/@react-aria/test-utils/src/select.ts b/packages/@react-aria/test-utils/src/select.ts index 5321e3c39a3..5f84dc4267d 100644 --- a/packages/@react-aria/test-utils/src/select.ts +++ b/packages/@react-aria/test-utils/src/select.ts @@ -183,7 +183,7 @@ export class SelectTester { return; } - if (document.activeElement !== listbox || !listbox.contains(document.activeElement)) { + if (document.activeElement !== listbox && !listbox.contains(document.activeElement)) { act(() => listbox.focus()); } await this.keyboardNavigateToOption({option}); diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 4609b4d63ca..ffb786a4dba 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -11,8 +11,8 @@ */ import {act, waitFor, within} from '@testing-library/react'; +import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridRowActionOpts, TableTesterOpts, ToggleGridRowOpts, UserOpts} from './types'; -import {pressElement, triggerLongPress} from './events'; interface TableToggleRowOpts extends ToggleGridRowOpts {} interface TableToggleSortOpts { @@ -48,6 +48,55 @@ export class TableTester { this._interactionType = type; } + // TODO: RTL + private async keyboardNavigateToRow(opts: {row: HTMLElement, useAltKey?: boolean}) { + let {row, useAltKey} = opts; + let altKey = getAltKey(); + let rows = this.rows; + let targetIndex = rows.indexOf(row); + if (targetIndex === -1) { + throw new Error('Row provided is not in the table'); + } + + // Move focus into the table + if (document.activeElement !== this._table && !this._table.contains(document.activeElement)) { + act(() => this._table.focus()); + } + + if (document.activeElement === this._table) { + await this.user.keyboard('[ArrowDown]'); + } + + // If focus is currently somewhere in the first row group (aka on a column), we want to keyboard navigate downwards till we reach the rows + if (this.rowGroups[0].contains(document.activeElement)) { + do { + await this.user.keyboard('[ArrowDown]'); + } while (!this.rowGroups[1].contains(document.activeElement)); + } + + // Move focus onto the row itself + if (this.rowGroups[1].contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + do { + await this.user.keyboard('[ArrowLeft]'); + } while (document.activeElement!.getAttribute('role') !== 'row'); + } + let currIndex = rows.indexOf(document.activeElement as HTMLElement); + if (currIndex === -1) { + throw new Error('Current active element is not on any of the table rows'); + } + let direction = targetIndex > currIndex ? 'down' : 'up'; + + if (useAltKey) { + await this.user.keyboard(`[${altKey}>]`); + } + for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) { + await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`); + } + if (useAltKey) { + await this.user.keyboard(`[/${altKey}]`); + } + }; + /** * Toggles the selection for the specified table row. Defaults to using the interaction type set on the table tester. */ @@ -56,9 +105,13 @@ export class TableTester { row, needsLongPress, checkboxSelection = true, - interactionType = this._interactionType + interactionType = this._interactionType, + selectionBehavior = 'toggle' } = opts; + let altKey = getMetaKey(); + let metaKey = getMetaKey(); + if (typeof row === 'string' || typeof row === 'number') { row = this.findRow({rowIndexOrText: row}); } @@ -69,12 +122,15 @@ export class TableTester { let rowCheckbox = within(row).queryByRole('checkbox'); - if (interactionType === 'keyboard' && !checkboxSelection) { - // TODO: for now focus the row directly until I add keyboard navigation - await act(async () => { - row.focus(); - }); - await this.user.keyboard('{Space}'); + if (interactionType === 'keyboard' && (!checkboxSelection || !rowCheckbox)) { + await this.keyboardNavigateToRow({row, useAltKey: selectionBehavior === 'replace'}); + if (selectionBehavior === 'replace') { + await this.user.keyboard(`[${altKey}>]`); + } + await this.user.keyboard('[Space]'); + if (selectionBehavior === 'replace') { + await this.user.keyboard(`[/${altKey}]`); + } return; } if (rowCheckbox && checkboxSelection) { @@ -89,7 +145,13 @@ export class TableTester { // Note that long press interactions with rows is strictly touch only for grid rows await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}}); } else { + if (selectionBehavior === 'replace' && interactionType !== 'touch') { + await this.user.keyboard(`[${metaKey}>]`); + } await pressElement(this.user, cell, interactionType); + if (selectionBehavior === 'replace' && interactionType !== 'touch') { + await this.user.keyboard(`[/${metaKey}]`); + } } } }; @@ -205,8 +267,7 @@ export class TableTester { if (needsDoubleClick) { await this.user.dblClick(row); } else if (interactionType === 'keyboard') { - // TODO: add keyboard navigation instead of focusing the row directly. Will need to consider if the focus in in the columns - act(() => row.focus()); + await this.keyboardNavigateToRow({row, useAltKey: true}); await this.user.keyboard('[Enter]'); } else { await pressElement(this.user, row, interactionType); diff --git a/packages/@react-aria/test-utils/src/tabs.ts b/packages/@react-aria/test-utils/src/tabs.ts index a51690bf25b..d52672c6aa0 100644 --- a/packages/@react-aria/test-utils/src/tabs.ts +++ b/packages/@react-aria/test-utils/src/tabs.ts @@ -137,7 +137,7 @@ export class TabsTester { } if (interactionType === 'keyboard') { - if (document.activeElement !== this._tablist || !this._tablist.contains(document.activeElement)) { + if (document.activeElement !== this._tablist && !this._tablist.contains(document.activeElement)) { act(() => this._tablist.focus()); } diff --git a/packages/@react-aria/test-utils/src/tree.ts b/packages/@react-aria/test-utils/src/tree.ts index c767948a43c..1fc98a0265c 100644 --- a/packages/@react-aria/test-utils/src/tree.ts +++ b/packages/@react-aria/test-utils/src/tree.ts @@ -12,7 +12,7 @@ import {act, within} from '@testing-library/react'; import {BaseGridRowInteractionOpts, GridRowActionOpts, ToggleGridRowOpts, TreeTesterOpts, UserOpts} from './types'; -import {pressElement, triggerLongPress} from './events'; +import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; interface TreeToggleExpansionOpts extends BaseGridRowInteractionOpts {} interface TreeToggleRowOpts extends ToggleGridRowOpts {} @@ -64,20 +64,21 @@ export class TreeTester { } // TODO: RTL - private async keyboardNavigateToRow(opts: {row: HTMLElement}) { - let {row} = opts; + private async keyboardNavigateToRow(opts: {row: HTMLElement, useAltKey?: boolean}) { + let {row, useAltKey} = opts; + let altKey = getAltKey(); let rows = this.rows; let targetIndex = rows.indexOf(row); if (targetIndex === -1) { throw new Error('Option provided is not in the tree'); } - if (document.activeElement !== this._tree || !this._tree.contains(document.activeElement)) { + if (document.activeElement !== this._tree && !this._tree.contains(document.activeElement)) { act(() => this._tree.focus()); } if (document.activeElement === this.tree) { - await this.user.keyboard('[ArrowDown]'); + await this.user.keyboard(`${useAltKey ? `[${altKey}>]` : ''}[ArrowDown]${useAltKey ? `[/${altKey}]` : ''}`); } else if (this._tree.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); @@ -89,22 +90,34 @@ export class TreeTester { } let direction = targetIndex > currIndex ? 'down' : 'up'; + if (useAltKey) { + await this.user.keyboard(`[${altKey}>]`); + } for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) { await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`); } + if (useAltKey) { + await this.user.keyboard(`[/${altKey}]`); + } }; /** * Toggles the selection for the specified tree row. Defaults to using the interaction type set on the tree tester. + * Note that this will endevor to always add/remove JUST the provided row to the set of selected rows. */ async toggleRowSelection(opts: TreeToggleRowOpts): Promise { let { row, needsLongPress, checkboxSelection = true, - interactionType = this._interactionType + interactionType = this._interactionType, + // TODO: perhaps this should just be shouldUseModifierKeys? + selectionBehavior = 'toggle' } = opts; + let altKey = getAltKey(); + let metaKey = getMetaKey(); + if (typeof row === 'string' || typeof row === 'number') { row = this.findRow({rowIndexOrText: row}); } @@ -123,9 +136,15 @@ export class TreeTester { // this would be better than the check to do nothing in events.ts // also, it'd be good to be able to trigger selection on the row instead of having to go to the checkbox directly - if (interactionType === 'keyboard' && !checkboxSelection) { - await this.keyboardNavigateToRow({row}); - await this.user.keyboard('{Space}'); + if (interactionType === 'keyboard' && (!checkboxSelection || !rowCheckbox)) { + await this.keyboardNavigateToRow({row, useAltKey: selectionBehavior === 'replace'}); + if (selectionBehavior === 'replace') { + await this.user.keyboard(`[${altKey}>]`); + } + await this.user.keyboard('[Space]'); + if (selectionBehavior === 'replace') { + await this.user.keyboard(`[/${altKey}]`); + } return; } if (rowCheckbox && checkboxSelection) { @@ -140,7 +159,14 @@ export class TreeTester { // Note that long press interactions with rows is strictly touch only for grid rows await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}}); } else { - await pressElement(this.user, cell, interactionType); + // TODO add modifiers here? Maybe move into pressElement if we get more cases for different types of modifier keys + if (selectionBehavior === 'replace' && interactionType !== 'touch') { + await this.user.keyboard(`[${metaKey}>]`); + } + await pressElement(this.user, row, interactionType); + if (selectionBehavior === 'replace' && interactionType !== 'touch') { + await this.user.keyboard(`[/${metaKey}]`); + } } } }; @@ -177,7 +203,10 @@ export class TreeTester { return; } - await this.keyboardNavigateToRow({row}); + // TODO: We always Use Option/Ctrl when keyboard navigating so selection isn't changed + // in selectionmode="replace"/highlight selection when navigating to the row that the user wants + // to expand. Discuss if this is useful or not + await this.keyboardNavigateToRow({row, useAltKey: true}); if (row.getAttribute('aria-expanded') === 'true') { await this.user.keyboard('[ArrowLeft]'); } else { @@ -211,7 +240,9 @@ export class TreeTester { return; } - await this.keyboardNavigateToRow({row}); + // TODO: same as above, uses the modifier key to make sure we don't modify selection state on row focus + // as we keyboard navigate to the row we want activate + await this.keyboardNavigateToRow({row, useAltKey: true}); await this.user.keyboard('[Enter]'); } else { await pressElement(this.user, row, interactionType); diff --git a/packages/@react-aria/test-utils/src/types.ts b/packages/@react-aria/test-utils/src/types.ts index c32415cb96f..cef8e41322b 100644 --- a/packages/@react-aria/test-utils/src/types.ts +++ b/packages/@react-aria/test-utils/src/types.ts @@ -126,7 +126,17 @@ export interface ToggleGridRowOpts extends BaseGridRowInteractionOpts { * Whether the checkbox should be used to select the row. If false, will attempt to select the row via press. * @default 'true' */ - checkboxSelection?: boolean + checkboxSelection?: boolean, + // TODO: this api feels a bit confusing tbh... + /** + * Whether the grid has a selectionBehavior of "toggle" or "replace" (aka highlight selection). This affects the user operations + * required to toggle row selection by adding modifier keys during user actions, useful when performing multi-row selection in a "selectionBehavior: 'replace'" grid. + * If you would like to still simulate user actions (aka press) without these modifiers keys for a "selectionBehavior: replace" grid, simply omit this option. + * See the "Selection Behavior" section of the appropriate React Aria Component docs for more information (e.g. https://react-spectrum.adobe.com/react-aria/Tree.html#selection-behavior). + * + * @default 'toggle' + */ + selectionBehavior?: 'toggle' | 'replace' } export interface GridRowActionOpts extends BaseGridRowInteractionOpts { diff --git a/packages/@react-spectrum/table/test/TestTableUtils.test.tsx b/packages/@react-spectrum/table/test/TestTableUtils.test.tsx index 45080322008..679bd61a285 100644 --- a/packages/@react-spectrum/table/test/TestTableUtils.test.tsx +++ b/packages/@react-spectrum/table/test/TestTableUtils.test.tsx @@ -111,12 +111,22 @@ describe('Table ', function () { let tableTester = testUtilRealTimer.createTester('Table', {root: screen.getByTestId('test')}); tableTester.setInteractionType(interactionType); await tableTester.toggleRowSelection({row: 2}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Foo 3'])); + // Called multiple times because highlight selections will change selection on focus unless you use + // a modifier key + if (interactionType === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 3'])); await tableTester.toggleRowSelection({row: 'Foo 4'}); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Foo 4'])); + if (interactionType === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 4'])); await tableTester.toggleSort({column: 2}); expect(onSortChange).toHaveBeenCalledTimes(1); @@ -163,6 +173,8 @@ describe('Table ', function () { ${'touch'} `('with fake timers, interactionType: $interactionType ', ({interactionType}) => { let testUtilFakeTimer = new User({advanceTimer: jest.advanceTimersByTime}); + // For proper touch simulation + installPointerEvent(); beforeAll(function () { jest.useFakeTimers(); }); @@ -208,12 +220,29 @@ describe('Table ', function () { tableTester.setInteractionType(interactionType); await tableTester.toggleRowSelection({row: 2}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Foo 3'])); + // Called multiple times because highlight selections will change selection on focus unless you use + // a modifier key + if (interactionType === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 3'])); await tableTester.toggleRowSelection({row: 'Foo 4'}); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Foo 4'])); + if (interactionType === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + + // touch always acts as toggle + if (interactionType === 'touch') { + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 3', 'Foo 4'])); + } else { + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 4'])); + } + await tableTester.toggleSort({column: 2}); expect(onSortChange).toHaveBeenCalledTimes(1); @@ -227,6 +256,58 @@ describe('Table ', function () { expect(onSortChange).toHaveBeenCalledTimes(3); expect(onSortChange).toHaveBeenLastCalledWith({column: 'foo', direction: 'descending'}); }); + + it('highlight selection with modifier key', async function () { + render(); + let tableTester = testUtilFakeTimer.createTester('Table', {root: screen.getByTestId('test')}); + tableTester.setInteractionType(interactionType); + + await tableTester.toggleRowSelection({row: 2, selectionBehavior: 'replace'}); + // Called multiple times because highlight selections will change selection on focus unless you use + // a modifier key + if (interactionType === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 1', 'Foo 3'])); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 3'])); + } + + await tableTester.toggleRowSelection({row: 'Foo 4', selectionBehavior: 'replace'}); + if (interactionType === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 1', 'Foo 3', 'Foo 4'])); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Foo 3', 'Foo 4'])); + } + }); + }); + + describe('long press, fake timers', () => { + installPointerEvent(); + let testUtilFakeTimer = new User({interactionType: 'touch', advanceTimer: jest.advanceTimersByTime}); + beforeAll(function () { + jest.useFakeTimers(); + }); + + afterEach(function () { + act(() => jest.runAllTimers()); + jest.clearAllMocks(); + }); + + it('highlight selection should switch to selection mode on long press', async function () { + render(); + let tableTester = testUtilFakeTimer.createTester('Table', {root: screen.getByTestId('test')}); + + await tableTester.toggleRowSelection({row: 2, needsLongPress: true}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Foo 3'])); + + await tableTester.toggleRowSelection({row: 'Foo 4'}); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Foo 3', 'Foo 4'])); + }); }); describe('long press, fake timers', () => { diff --git a/packages/@react-spectrum/tree/test/TreeView.test.tsx b/packages/@react-spectrum/tree/test/TreeView.test.tsx index 8f411a81163..41ba410a449 100644 --- a/packages/@react-spectrum/tree/test/TreeView.test.tsx +++ b/packages/@react-spectrum/tree/test/TreeView.test.tsx @@ -19,11 +19,11 @@ import Edit from '@spectrum-icons/workflow/Edit'; import Folder from '@spectrum-icons/workflow/Folder'; import {Heading, Text} from '@react-spectrum/text'; import {IllustratedMessage} from '@react-spectrum/illustratedmessage'; +import {installPointerEvent, User} from '@react-aria/test-utils'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; import {TreeView, TreeViewItem, TreeViewItemContent} from '../'; -import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; let onSelectionChange = jest.fn(); @@ -695,6 +695,211 @@ describe('Tree', () => { expect(onScroll).toHaveBeenCalled(); }); + describe('highlight selection', () => { + // Required for proper touch detection + installPointerEvent(); + describe.each(['mouse', 'keyboard', 'touch'])('%s', (type) => { + it('should perform selection for highlight mode with single selection', async () => { + let {getByRole} = render(); + let treeTester = testUtilUser.createTester('Tree', {user, root: getByRole('treegrid'), interactionType: type as 'keyboard' | 'mouse' | 'touch'}); + let rows = treeTester.rows; + + for (let row of treeTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'single'); + } + + let row2 = rows[2]; + await treeTester.toggleRowSelection({row: 'Projects-1', selectionBehavior: 'replace'}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called twice because initial focus will select the first keyboard focused row + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(1); + expect(treeTester.selectedRows[0]).toBe(row2); + + let row1 = rows[1]; + await treeTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Projects'])); + expect(treeTester.selectedRows).toHaveLength(1); + expect(treeTester.selectedRows[0]).toBe(row1); + + await treeTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set([])); + expect(treeTester.selectedRows).toHaveLength(0); + }); + + it('should perform toggle selection in highlight mode when using modifier keys', async () => { + let {getByRole} = render(); + let treeTester = testUtilUser.createTester('Tree', {user, root: getByRole('treegrid'), interactionType: type as 'keyboard' | 'mouse' | 'touch'}); + let rows = treeTester.rows; + + for (let row of treeTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'multiple'); + } + + let row2 = rows[2]; + await treeTester.toggleRowSelection({row: 'Projects-1', selectionBehavior: 'replace'}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called twice because initial focus will select the first keyboard focused row, meaning we have two items selected + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Photos', 'Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(2); + expect(treeTester.selectedRows[1]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(1); + expect(treeTester.selectedRows[0]).toBe(row2); + } + + let row1 = rows[1]; + await treeTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Photos', 'Projects-1', 'Projects'])); + expect(treeTester.selectedRows).toHaveLength(3); + expect(treeTester.selectedRows[1]).toBe(row1); + expect(treeTester.selectedRows[2]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Projects-1', 'Projects'])); + expect(treeTester.selectedRows).toHaveLength(2); + expect(treeTester.selectedRows[0]).toBe(row1); + expect(treeTester.selectedRows[1]).toBe(row2); + } + + // With modifier key, you should be able to deselect on press of the same row + await treeTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Photos', 'Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(2); + expect(treeTester.selectedRows[1]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(1); + expect(treeTester.selectedRows[0]).toBe(row2); + } + }); + + it('should perform replace selection in highlight mode when not using modifier keys', async () => { + let {getByRole} = render(); + let treeTester = testUtilUser.createTester('Tree', {user, root: getByRole('treegrid'), interactionType: type as 'keyboard' | 'mouse' | 'touch'}); + let rows = treeTester.rows; + + for (let row of treeTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'multiple'); + } + + let row2 = rows[2]; + await treeTester.toggleRowSelection({row: 'Projects-1'}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called multiple times since selection changes on option focus as we arrow down to the target option + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(1); + expect(treeTester.selectedRows[0]).toBe(row2); + + let row1 = rows[1]; + await treeTester.toggleRowSelection({row: row1}); + if (type !== 'touch') { + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['Projects'])); + expect(treeTester.selectedRows).toHaveLength(1); + expect(treeTester.selectedRows[0]).toBe(row1); + + // pressing without modifier keys won't deselect the row + await treeTester.toggleRowSelection({row: row1}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(treeTester.selectedRows).toHaveLength(1); + } else { + // touch always behaves as toggle + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Projects', 'Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(2); + expect(treeTester.selectedRows[0]).toBe(row1); + + await treeTester.toggleRowSelection({row: row1}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(1); + expect(treeTester.selectedRows[0]).toBe(row2); + } + }); + }); + }); + describe('links', function () { describe.each(['mouse', 'keyboard'])('%s', (type) => { let trigger = async (item, key = 'Enter') => { diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index c3a437229c4..0f207149688 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -31,8 +31,8 @@ import { Virtualizer } from '../'; import {getFocusableTreeWalker} from '@react-aria/focus'; +import {installPointerEvent, User} from '@react-aria/test-utils'; import React from 'react'; -import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; let TestGridList = ({listBoxProps, itemProps}) => ( @@ -63,6 +63,7 @@ let renderGridList = (listBoxProps, itemProps) => render( { let user; let testUtilUser = new User(); + let onSelectionChange = jest.fn(); beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); @@ -74,6 +75,10 @@ describe('GridList', () => { jest.clearAllMocks(); }); + afterAll(() => { + jest.restoreAllMocks(); + }); + it('should render with default classes', () => { let {getByRole} = renderGridList(); let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); @@ -410,6 +415,220 @@ describe('GridList', () => { expect(items[2]).toHaveAttribute('aria-selected', 'true'); }); + describe('selectionBehavior="replace"', () => { + // Required for proper touch detection + installPointerEvent(); + let GridListNoCheckboxes = ({listBoxProps, itemProps}) => ( + + Cat + Dog + Kangaroo + + ); + + describe.each(['mouse', 'keyboard', 'touch'])('%s', (type) => { + it('should perform selection with single selection', async () => { + let {getByRole} = render(); + let gridListTester = testUtilUser.createTester('GridList', {user, root: getByRole('grid'), interactionType: type}); + let rows = gridListTester.rows; + + for (let row of gridListTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'single'); + } + + let row2 = rows[2]; + expect(onSelectionChange).toHaveBeenCalledTimes(0); + await gridListTester.toggleRowSelection({row: 'Kangaroo', selectionBehavior: 'replace'}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called twice because initial focus will select the first keyboard focused row + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(1); + expect(gridListTester.selectedRows[0]).toBe(row2); + + let row1 = rows[1]; + await gridListTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['dog'])); + expect(gridListTester.selectedRows).toHaveLength(1); + expect(gridListTester.selectedRows[0]).toBe(row1); + + await gridListTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set([])); + expect(gridListTester.selectedRows).toHaveLength(0); + }); + + it('should perform toggle selection in highlight mode when using modifier keys', async () => { + let {getByRole} = render(); + let gridListTester = testUtilUser.createTester('GridList', {user, root: getByRole('grid'), interactionType: type}); + let rows = gridListTester.rows; + + for (let row of gridListTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'multiple'); + } + + let row2 = rows[2]; + await gridListTester.toggleRowSelection({row: 'Kangaroo', selectionBehavior: 'replace'}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called twice because initial focus will select the first keyboard focused row, meaning we have two items selected + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['cat', 'kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(2); + expect(gridListTester.selectedRows[1]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(1); + expect(gridListTester.selectedRows[0]).toBe(row2); + } + + let row1 = rows[1]; + await gridListTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['cat', 'dog', 'kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(3); + expect(gridListTester.selectedRows[1]).toBe(row1); + expect(gridListTester.selectedRows[2]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['dog', 'kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(2); + expect(gridListTester.selectedRows[0]).toBe(row1); + expect(gridListTester.selectedRows[1]).toBe(row2); + } + + // With modifier key, you should be able to deselect on press of the same row + await gridListTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['cat', 'kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(2); + expect(gridListTester.selectedRows[1]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(1); + expect(gridListTester.selectedRows[0]).toBe(row2); + } + }); + + it('should perform replace selection in highlight mode when not using modifier keys', async () => { + let {getByRole} = render(); + let gridListTester = testUtilUser.createTester('GridList', {user, root: getByRole('grid'), interactionType: type}); + let rows = gridListTester.rows; + + for (let row of gridListTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'multiple'); + } + + let row2 = rows[2]; + await gridListTester.toggleRowSelection({row: 'Kangaroo'}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called multiple times since selection changes on option focus as we arrow down to the target option + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(1); + expect(gridListTester.selectedRows[0]).toBe(row2); + + let row1 = rows[1]; + await gridListTester.toggleRowSelection({row: row1}); + if (type !== 'touch') { + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['dog'])); + expect(gridListTester.selectedRows).toHaveLength(1); + expect(gridListTester.selectedRows[0]).toBe(row1); + + // pressing without modifier keys won't deselect the row + await gridListTester.toggleRowSelection({row: row1}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(gridListTester.selectedRows).toHaveLength(1); + } else { + // touch always behaves as toggle + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['dog', 'kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(2); + expect(gridListTester.selectedRows[0]).toBe(row1); + + await gridListTester.toggleRowSelection({row: row1}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['kangaroo'])); + expect(gridListTester.selectedRows).toHaveLength(1); + expect(gridListTester.selectedRows[0]).toBe(row2); + } + }); + }); + }); + it('should support virtualizer', async () => { let items = []; for (let i = 0; i < 50; i++) { @@ -827,9 +1046,9 @@ describe('GridList', () => { let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -839,9 +1058,9 @@ describe('GridList', () => { let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -851,9 +1070,9 @@ describe('GridList', () => { let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}); let items = getAllByRole('row'); - await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(0); - + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 10b9370a4e7..772f8c06825 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -62,6 +62,7 @@ let keyPress = (key) => { describe('ListBox', () => { let user; let testUtilUser = new User(); + let onSelectionChange = jest.fn(); beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); @@ -573,6 +574,187 @@ describe('ListBox', () => { expect(onAction).toHaveBeenCalledTimes(1); }); + describe('selectionBehavior="replace"', () => { + // Required for proper touch detection + installPointerEvent(); + describe.each(['mouse', 'keyboard', 'touch'])('%s', (type) => { + it('should perform selection with single selection', async () => { + let {getByRole} = renderListbox({selectionMode: 'single', selectionBehavior: 'replace', onSelectionChange}); + let listboxTester = testUtilUser.createTester('ListBox', {root: getByRole('listbox'), interactionType: type}); + let options = listboxTester.options(); + + expect(onSelectionChange).toHaveBeenCalledTimes(0); + let option2 = options[2]; + await listboxTester.toggleOptionSelection({option: 'Kangaroo', selectionBehavior: 'replace'}); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called twice because initial focus will select the first keyboard focused row + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(1); + expect(listboxTester.selectedOptions[0]).toBe(option2); + + let option1 = options[1]; + await listboxTester.toggleOptionSelection({option: option1, selectionBehavior: 'replace'}); + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option1).toHaveAttribute('data-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'false'); + expect(option2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['dog'])); + expect(listboxTester.selectedOptions).toHaveLength(1); + expect(listboxTester.selectedOptions[0]).toBe(option1); + + await listboxTester.toggleOptionSelection({option: option1, selectionBehavior: 'replace'}); + expect(option1).toHaveAttribute('aria-selected', 'false'); + expect(option1).not.toHaveAttribute('data-selected'); + expect(option2).toHaveAttribute('aria-selected', 'false'); + expect(option2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set([])); + expect(listboxTester.selectedOptions).toHaveLength(0); + }); + + it('should perform toggle selection in highlight mode when using modifier keys', async () => { + let {getByRole} = renderListbox({selectionMode: 'multiple', selectionBehavior: 'replace', onSelectionChange}); + let listboxTester = testUtilUser.createTester('ListBox', {root: getByRole('listbox'), interactionType: type}); + let options = listboxTester.options(); + + let option2 = options[2]; + await listboxTester.toggleOptionSelection({option: 'Kangaroo', selectionBehavior: 'replace'}); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called twice because initial focus will select the first keyboard focused row, meaning we have two items selected + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['cat', 'kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(2); + expect(listboxTester.selectedOptions[1]).toBe(option2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(1); + expect(listboxTester.selectedOptions[0]).toBe(option2); + } + + let option1 = options[1]; + await listboxTester.toggleOptionSelection({option: option1, selectionBehavior: 'replace'}); + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option1).toHaveAttribute('data-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['cat', 'dog', 'kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(3); + expect(listboxTester.selectedOptions[1]).toBe(option1); + expect(listboxTester.selectedOptions[2]).toBe(option2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['dog', 'kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(2); + expect(listboxTester.selectedOptions[0]).toBe(option1); + expect(listboxTester.selectedOptions[1]).toBe(option2); + } + + // With modifier key, you should be able to deselect on press of the same row + await listboxTester.toggleOptionSelection({option: option1, selectionBehavior: 'replace'}); + expect(option1).toHaveAttribute('aria-selected', 'false'); + expect(option1).not.toHaveAttribute('data-selected'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['cat', 'kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(2); + expect(listboxTester.selectedOptions[1]).toBe(option2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(1); + expect(listboxTester.selectedOptions[0]).toBe(option2); + } + }); + + it('should perform replace selection in highlight mode when not using modifier keys', async () => { + let {getByRole} = renderListbox({selectionMode: 'multiple', selectionBehavior: 'replace', onSelectionChange}); + let listboxTester = testUtilUser.createTester('ListBox', {root: getByRole('listbox'), interactionType: type}); + let options = listboxTester.options(); + + let option2 = options[2]; + await listboxTester.toggleOptionSelection({option: 'Kangaroo'}); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called multiple times since selection changes on option focus as we arrow down to the target option + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(1); + expect(listboxTester.selectedOptions[0]).toBe(option2); + + let option1 = options[1]; + await listboxTester.toggleOptionSelection({option: option1}); + if (type !== 'touch') { + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option1).toHaveAttribute('data-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'false'); + expect(option2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['dog'])); + expect(listboxTester.selectedOptions).toHaveLength(1); + expect(listboxTester.selectedOptions[0]).toBe(option1); + // pressing without modifier keys won't deselect the row + await listboxTester.toggleOptionSelection({option: option1}); + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option1).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(listboxTester.selectedOptions).toHaveLength(1); + } else { + // touch always behaves as toggle + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option1).toHaveAttribute('data-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('data-selected', 'true'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['dog', 'kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(2); + expect(listboxTester.selectedOptions[0]).toBe(option1); + + await listboxTester.toggleOptionSelection({option: option1}); + expect(option1).toHaveAttribute('aria-selected', 'false'); + expect(option1).not.toHaveAttribute('data-selected'); + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['kangaroo'])); + expect(listboxTester.selectedOptions).toHaveLength(1); + expect(listboxTester.selectedOptions[0]).toBe(option2); + } + }); + }); + }); + describe('with pointer events', () => { installPointerEvent(); it('should trigger selection on long press if both onAction and selection exist (touch only)', async () => { diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 31a2f7bed0f..7ddd03259a8 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -206,6 +206,7 @@ let renderTable = (props) => render(); describe('Table', () => { let user; let testUtilUser = new User(); + let onSelectionChange = jest.fn(); beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); }); @@ -2169,15 +2170,240 @@ describe('Table', () => { }); }); + describe('selectionBehavior="replace"', () => { + // Required for proper touch detection + installPointerEvent(); + + describe.each(['mouse', 'keyboard', 'touch'])('%s', (type) => { + it('should perform selection with single selection', async () => { + let {getByRole} = renderTable({ + tableProps: { + selectionMode: 'single', + selectionBehavior: 'replace', + onSelectionChange + } + }); + let tableTester = testUtilUser.createTester('Table', {user, root: getByRole('grid'), interactionType: type}); + let rows = tableTester.rows; + + for (let row of tableTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'single'); + } + + let row2 = rows[2]; + expect(onSelectionChange).toHaveBeenCalledTimes(0); + await tableTester.toggleRowSelection({row: row2, selectionBehavior: 'replace'}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called twice because initial focus will select the first keyboard focused row + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['3'])); + expect(tableTester.selectedRows).toHaveLength(1); + expect(tableTester.selectedRows[0]).toBe(row2); + + let row1 = rows[1]; + await tableTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['2'])); + expect(tableTester.selectedRows).toHaveLength(1); + expect(tableTester.selectedRows[0]).toBe(row1); + + await tableTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set([])); + expect(tableTester.selectedRows).toHaveLength(0); + }); + + it('should perform toggle selection in highlight mode when using modifier keys', async () => { + let {getByRole} = renderTable({ + tableProps: { + selectionMode: 'multiple', + selectionBehavior: 'replace', + onSelectionChange + } + }); + let tableTester = testUtilUser.createTester('Table', {user, root: getByRole('grid'), interactionType: type}); + let rows = tableTester.rows; + + for (let row of tableTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'multiple'); + } + + let row2 = rows[2]; + await tableTester.toggleRowSelection({row: row2, selectionBehavior: 'replace'}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called twice because initial focus will select the first keyboard focused row, meaning we have two items selected + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['1', '3'])); + expect(tableTester.selectedRows).toHaveLength(2); + expect(tableTester.selectedRows[1]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['3'])); + expect(tableTester.selectedRows).toHaveLength(1); + expect(tableTester.selectedRows[0]).toBe(row2); + } + + let row1 = rows[1]; + await tableTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['1', '2', '3'])); + expect(tableTester.selectedRows).toHaveLength(3); + expect(tableTester.selectedRows[1]).toBe(row1); + expect(tableTester.selectedRows[2]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['2', '3'])); + expect(tableTester.selectedRows).toHaveLength(2); + expect(tableTester.selectedRows[0]).toBe(row1); + expect(tableTester.selectedRows[1]).toBe(row2); + } + + // With modifier key, you should be able to deselect on press of the same row + await tableTester.toggleRowSelection({row: row1, selectionBehavior: 'replace'}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['1', '3'])); + expect(tableTester.selectedRows).toHaveLength(2); + expect(tableTester.selectedRows[1]).toBe(row2); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['3'])); + expect(tableTester.selectedRows).toHaveLength(1); + expect(tableTester.selectedRows[0]).toBe(row2); + } + }); + + it('should perform replace selection in highlight mode when not using modifier keys', async () => { + let {getByRole} = renderTable({ + tableProps: { + selectionMode: 'multiple', + selectionBehavior: 'replace', + onSelectionChange + } + }); + let tableTester = testUtilUser.createTester('Table', {user, root: getByRole('grid'), interactionType: type}); + let rows = tableTester.rows; + + for (let row of tableTester.rows) { + let checkbox = within(row).queryByRole('checkbox'); + expect(checkbox).toBeNull(); + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).toHaveAttribute('data-selection-mode', 'multiple'); + } + + let row2 = rows[2]; + await tableTester.toggleRowSelection({row: row2}); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + // Called multiple times since selection changes on option focus as we arrow down to the target option + expect(onSelectionChange).toHaveBeenCalledTimes(3); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['3'])); + expect(tableTester.selectedRows).toHaveLength(1); + expect(tableTester.selectedRows[0]).toBe(row2); + + let row1 = rows[1]; + await tableTester.toggleRowSelection({row: row1}); + if (type !== 'touch') { + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'false'); + expect(row2).not.toHaveAttribute('data-selected'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(new Set(onSelectionChange.mock.calls.at(-1)[0])).toEqual(new Set(['2'])); + expect(tableTester.selectedRows).toHaveLength(1); + expect(tableTester.selectedRows[0]).toBe(row1); + + // pressing without modifier keys won't deselect the row + await tableTester.toggleRowSelection({row: row1}); + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + if (type === 'keyboard') { + expect(onSelectionChange).toHaveBeenCalledTimes(4); + } else { + expect(onSelectionChange).toHaveBeenCalledTimes(2); + } + expect(tableTester.selectedRows).toHaveLength(1); + } else { + // touch always behaves as toggle + expect(row1).toHaveAttribute('aria-selected', 'true'); + expect(row1).toHaveAttribute('data-selected', 'true'); + expect(row2).toHaveAttribute('aria-selected', 'true'); + expect(row2).toHaveAttribute('data-selected', 'true'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['2', '3'])); + expect(tableTester.selectedRows).toHaveLength(2); + expect(tableTester.selectedRows[0]).toBe(row1); + + await tableTester.toggleRowSelection({row: row1}); + expect(row1).toHaveAttribute('aria-selected', 'false'); + expect(row1).not.toHaveAttribute('data-selected'); + expect(onSelectionChange).toHaveBeenCalledTimes(3); + expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['3'])); + expect(tableTester.selectedRows).toHaveLength(1); + expect(tableTester.selectedRows[0]).toBe(row2); + } + }); + }); + }); + describe('shouldSelectOnPressUp', () => { it('should select an item on pressing down when shouldSelectOnPressUp is not provided', async () => { let onSelectionChange = jest.fn(); let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -2187,9 +2413,9 @@ describe('Table', () => { let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(1); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); @@ -2199,9 +2425,9 @@ describe('Table', () => { let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}}); let items = getAllByRole('row'); - await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); expect(onSelectionChange).toBeCalledTimes(0); - + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); expect(onSelectionChange).toBeCalledTimes(1); }); diff --git a/yarn.lock b/yarn.lock index 1908468e5ff..ef05ed1f366 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6728,6 +6728,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/test-utils@workspace:packages/@react-aria/test-utils" dependencies: + "@react-aria/utils": "npm:^3.28.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: "@testing-library/react": ^16.0.0 @@ -6839,7 +6840,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/utils@npm:^3.14.2, @react-aria/utils@npm:^3.28.2, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils": +"@react-aria/utils@npm:^3.14.2, @react-aria/utils@npm:^3.28.1, @react-aria/utils@npm:^3.28.2, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils": version: 0.0.0-use.local resolution: "@react-aria/utils@workspace:packages/@react-aria/utils" dependencies: