Skip to content

Commit 21d1950

Browse files
authored
feat: support selectionMode="replace" in grid collection test utils (#8028)
* attempt to get rid of jest calls in menu util * update RSP testing docs to directly mention mocks that maybe needed * bump versions of RTL to 16 * use alternative to calling jest run timers in menu option selection * fixing types and properly testing long press * fix lint * revert to pre testing library bump for clean slate * fix build and another submenu edge case now we shouldnt need to call runAllTimers after selectOption * fix react 16 bug * update return type of advanceTimer and docs copy * Initial support for tree highlight selection support * move some general fixes from selectionMode="replace" branch here * add highlight selection support to gridlist, listbox, and table * add test for deselection with modifier and add gridlist tests * fix build * add listbox test and fix logic for keyboard selection in utils if a checkbox wasnt present we werent using the keyboard navigate logic flow * add table util highlight selection tests and add proper keyboard navigation simulation to util * remove dep on react-aria/utils * update yarn lock * review comments * update prop name for clarity * fix case where S2 Combobox and Picker returns wrong trigger if contextual help is provided
1 parent f89809d commit 21d1950

File tree

17 files changed

+1249
-80
lines changed

17 files changed

+1249
-80
lines changed

packages/@react-aria/test-utils/src/combobox.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,17 @@ export class ComboBoxTester {
5555
if (trigger) {
5656
this._trigger = trigger;
5757
} else {
58-
let trigger = within(root).queryByRole('button', {hidden: true});
59-
if (trigger) {
60-
this._trigger = trigger;
61-
} else {
62-
// For cases like https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ where the combobox
63-
// is also the trigger button
64-
this._trigger = this._combobox;
58+
let buttons = within(root).queryAllByRole('button', {hidden: true});
59+
60+
if (buttons.length === 1) {
61+
trigger = buttons[0];
62+
} else if (buttons.length > 1) {
63+
trigger = buttons.find(button => button.hasAttribute('aria-haspopup'));
6564
}
65+
66+
// For cases like https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ where the combobox
67+
// is also the trigger button
68+
this._trigger = trigger || this._combobox;
6669
}
6770
}
6871

packages/@react-aria/test-utils/src/events.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,37 @@ import {act, fireEvent} from '@testing-library/react';
1414
import {UserOpts} from './types';
1515

1616
export const DEFAULT_LONG_PRESS_TIME = 500;
17+
function testPlatform(re: RegExp) {
18+
return typeof window !== 'undefined' && window.navigator != null
19+
? re.test(window.navigator['userAgentData']?.platform || window.navigator.platform)
20+
: false;
21+
}
22+
23+
function cached(fn: () => boolean) {
24+
if (process.env.NODE_ENV === 'test') {
25+
return fn;
26+
}
27+
28+
let res: boolean | null = null;
29+
return () => {
30+
if (res == null) {
31+
res = fn();
32+
}
33+
return res;
34+
};
35+
}
36+
37+
const isMac = cached(function () {
38+
return testPlatform(/^Mac/i);
39+
});
40+
41+
export function getAltKey(): 'Alt' | 'ControlLeft' {
42+
return isMac() ? 'Alt' : 'ControlLeft';
43+
}
44+
45+
export function getMetaKey(): 'MetaLeft' | 'ControlLeft' {
46+
return isMac() ? 'MetaLeft' : 'ControlLeft';
47+
}
1748

1849
/**
1950
* Simulates a "long press" event on a element.
@@ -58,9 +89,10 @@ export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer
5889
}
5990

6091
// Docs cannot handle the types that userEvent actually declares, so hopefully this sub set is okay
61-
export async function pressElement(user: {click: (element: Element) => Promise<void>, keyboard: (keys: string) => Promise<void>, pointer: (opts: {target: Element, keys: string}) => Promise<void>}, element: HTMLElement, interactionType: UserOpts['interactionType']): Promise<void> {
92+
export async function pressElement(user: {click: (element: Element) => Promise<void>, keyboard: (keys: string) => Promise<void>, pointer: (opts: {target: Element, keys: string, coords?: any}) => Promise<void>}, element: HTMLElement, interactionType: UserOpts['interactionType']): Promise<void> {
6293
if (interactionType === 'mouse') {
63-
await user.click(element);
94+
// Add coords with pressure so this isn't detected as a virtual click
95+
await user.pointer({target: element, keys: '[MouseLeft]', coords: {pressure: .5}});
6496
} else if (interactionType === 'keyboard') {
6597
// 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
6698
// stick to simulting an actual user's keyboard operations as closely as possible

packages/@react-aria/test-utils/src/gridlist.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
*/
1212

1313
import {act, within} from '@testing-library/react';
14+
import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events';
1415
import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types';
15-
import {pressElement, triggerLongPress} from './events';
1616

1717
interface GridListToggleRowOpts extends ToggleGridRowOpts {}
1818
interface GridListRowActionOpts extends GridRowActionOpts {}
@@ -57,20 +57,21 @@ export class GridListTester {
5757
}
5858

5959
// TODO: RTL
60-
private async keyboardNavigateToRow(opts: {row: HTMLElement}) {
61-
let {row} = opts;
60+
private async keyboardNavigateToRow(opts: {row: HTMLElement, selectionOnNav?: 'default' | 'none'}) {
61+
let {row, selectionOnNav = 'default'} = opts;
62+
let altKey = getAltKey();
6263
let rows = this.rows;
6364
let targetIndex = rows.indexOf(row);
6465
if (targetIndex === -1) {
6566
throw new Error('Option provided is not in the gridlist');
6667
}
6768

68-
if (document.activeElement !== this._gridlist || !this._gridlist.contains(document.activeElement)) {
69+
if (document.activeElement !== this._gridlist && !this._gridlist.contains(document.activeElement)) {
6970
act(() => this._gridlist.focus());
7071
}
7172

7273
if (document.activeElement === this._gridlist) {
73-
await this.user.keyboard('[ArrowDown]');
74+
await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`);
7475
} else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') {
7576
do {
7677
await this.user.keyboard('[ArrowLeft]');
@@ -82,22 +83,33 @@ export class GridListTester {
8283
}
8384
let direction = targetIndex > currIndex ? 'down' : 'up';
8485

86+
if (selectionOnNav === 'none') {
87+
await this.user.keyboard(`[${altKey}>]`);
88+
}
8589
for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
8690
await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
8791
}
92+
if (selectionOnNav === 'none') {
93+
await this.user.keyboard(`[/${altKey}]`);
94+
}
8895
};
8996

9097
/**
9198
* Toggles the selection for the specified gridlist row. Defaults to using the interaction type set on the gridlist tester.
99+
* Note that this will endevor to always add/remove JUST the provided row to the set of selected rows.
92100
*/
93101
async toggleRowSelection(opts: GridListToggleRowOpts): Promise<void> {
94102
let {
95103
row,
96104
needsLongPress,
97105
checkboxSelection = true,
98-
interactionType = this._interactionType
106+
interactionType = this._interactionType,
107+
selectionBehavior = 'toggle'
99108
} = opts;
100109

110+
let altKey = getAltKey();
111+
let metaKey = getMetaKey();
112+
101113
if (typeof row === 'string' || typeof row === 'number') {
102114
row = this.findRow({rowIndexOrText: row});
103115
}
@@ -116,9 +128,15 @@ export class GridListTester {
116128

117129
// this would be better than the check to do nothing in events.ts
118130
// also, it'd be good to be able to trigger selection on the row instead of having to go to the checkbox directly
119-
if (interactionType === 'keyboard' && !checkboxSelection) {
120-
await this.keyboardNavigateToRow({row});
121-
await this.user.keyboard('{Space}');
131+
if (interactionType === 'keyboard' && (!checkboxSelection || !rowCheckbox)) {
132+
await this.keyboardNavigateToRow({row, selectionOnNav: selectionBehavior === 'replace' ? 'none' : 'default'});
133+
if (selectionBehavior === 'replace') {
134+
await this.user.keyboard(`[${altKey}>]`);
135+
}
136+
await this.user.keyboard('[Space]');
137+
if (selectionBehavior === 'replace') {
138+
await this.user.keyboard(`[/${altKey}]`);
139+
}
122140
return;
123141
}
124142
if (rowCheckbox && checkboxSelection) {
@@ -132,9 +150,14 @@ export class GridListTester {
132150

133151
// Note that long press interactions with rows is strictly touch only for grid rows
134152
await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}});
135-
136153
} else {
137-
await pressElement(this.user, cell, interactionType);
154+
if (selectionBehavior === 'replace' && interactionType !== 'touch') {
155+
await this.user.keyboard(`[${metaKey}>]`);
156+
}
157+
await pressElement(this.user, row, interactionType);
158+
if (selectionBehavior === 'replace' && interactionType !== 'touch') {
159+
await this.user.keyboard(`[/${metaKey}]`);
160+
}
138161
}
139162
}
140163
}
@@ -166,7 +189,7 @@ export class GridListTester {
166189
return;
167190
}
168191

169-
await this.keyboardNavigateToRow({row});
192+
await this.keyboardNavigateToRow({row, selectionOnNav: 'none'});
170193
await this.user.keyboard('[Enter]');
171194
} else {
172195
await pressElement(this.user, row, interactionType);

packages/@react-aria/test-utils/src/listbox.ts

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
*/
1212

1313
import {act, within} from '@testing-library/react';
14+
import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events';
1415
import {ListBoxTesterOpts, UserOpts} from './types';
15-
import {pressElement, triggerLongPress} from './events';
1616

1717
interface ListBoxToggleOptionOpts {
1818
/**
@@ -31,7 +31,16 @@ interface ListBoxToggleOptionOpts {
3131
/**
3232
* Whether the option needs to be long pressed to be selected. Depends on the listbox's implementation.
3333
*/
34-
needsLongPress?: boolean
34+
needsLongPress?: boolean,
35+
/**
36+
* Whether the listbox has a selectionBehavior of "toggle" or "replace" (aka highlight selection). This affects the user operations
37+
* required to toggle option selection by adding modifier keys during user actions, useful when performing multi-option selection in a "selectionBehavior: 'replace'" listbox.
38+
* If you would like to still simulate user actions (aka press) without these modifiers keys for a "selectionBehavior: replace" listbox, simply omit this option.
39+
* See the [RAC Listbox docs](https://react-spectrum.adobe.com/react-aria/ListBox.html#selection-behavior) for more info on this behavior.
40+
*
41+
* @default 'toggle'
42+
*/
43+
selectionBehavior?: 'toggle' | 'replace'
3544
}
3645

3746
interface ListBoxOptionActionOpts extends Omit<ListBoxToggleOptionOpts, 'keyboardActivation' | 'needsLongPress'> {
@@ -85,44 +94,51 @@ export class ListBoxTester {
8594

8695
// TODO: this is basically the same as menu except for the error message, refactor later so that they share
8796
// TODO: this also doesn't support grid layout yet
88-
private async keyboardNavigateToOption(opts: {option: HTMLElement}) {
89-
let {option} = opts;
97+
private async keyboardNavigateToOption(opts: {option: HTMLElement, selectionOnNav?: 'default' | 'none'}) {
98+
let {option, selectionOnNav = 'default'} = opts;
99+
let altKey = getAltKey();
90100
let options = this.options();
91101
let targetIndex = options.indexOf(option);
92102
if (targetIndex === -1) {
93103
throw new Error('Option provided is not in the listbox');
94104
}
95105

96-
if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) {
106+
if (document.activeElement !== this._listbox && !this._listbox.contains(document.activeElement)) {
97107
act(() => this._listbox.focus());
98-
}
99-
100-
await this.user.keyboard('[ArrowDown]');
101-
102-
// TODO: not sure about doing same while loop that exists in other implementations of keyboardNavigateToOption,
103-
// feels like it could break easily
104-
if (document.activeElement?.getAttribute('role') !== 'option') {
105-
await act(async () => {
106-
option.focus();
107-
});
108+
await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`);
108109
}
109110

110111
let currIndex = options.indexOf(document.activeElement as HTMLElement);
111112
if (currIndex === -1) {
112113
throw new Error('ActiveElement is not in the listbox');
113114
}
114-
let direction = targetIndex > currIndex ? 'down' : 'up';
115115

116+
let direction = targetIndex > currIndex ? 'down' : 'up';
117+
if (selectionOnNav === 'none') {
118+
await this.user.keyboard(`[${altKey}>]`);
119+
}
116120
for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
117121
await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
118122
}
123+
if (selectionOnNav === 'none') {
124+
await this.user.keyboard(`[/${altKey}]`);
125+
}
119126
};
120127

121128
/**
122129
* Toggles the selection for the specified listbox option. Defaults to using the interaction type set on the listbox tester.
123130
*/
124131
async toggleOptionSelection(opts: ListBoxToggleOptionOpts): Promise<void> {
125-
let {option, needsLongPress, keyboardActivation = 'Enter', interactionType = this._interactionType} = opts;
132+
let {
133+
option,
134+
needsLongPress,
135+
keyboardActivation = 'Enter',
136+
interactionType = this._interactionType,
137+
selectionBehavior = 'toggle'
138+
} = opts;
139+
140+
let altKey = getAltKey();
141+
let metaKey = getMetaKey();
126142

127143
if (typeof option === 'string' || typeof option === 'number') {
128144
option = this.findOption({optionIndexOrText: option});
@@ -137,8 +153,14 @@ export class ListBoxTester {
137153
return;
138154
}
139155

140-
await this.keyboardNavigateToOption({option});
156+
await this.keyboardNavigateToOption({option, selectionOnNav: selectionBehavior === 'replace' ? 'none' : 'default'});
157+
if (selectionBehavior === 'replace') {
158+
await this.user.keyboard(`[${altKey}>]`);
159+
}
141160
await this.user.keyboard(`[${keyboardActivation}]`);
161+
if (selectionBehavior === 'replace') {
162+
await this.user.keyboard(`[/${altKey}]`);
163+
}
142164
} else {
143165
if (needsLongPress && interactionType === 'touch') {
144166
if (this._advanceTimer == null) {
@@ -147,7 +169,13 @@ export class ListBoxTester {
147169

148170
await triggerLongPress({element: option, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}});
149171
} else {
172+
if (selectionBehavior === 'replace' && interactionType !== 'touch') {
173+
await this.user.keyboard(`[${metaKey}>]`);
174+
}
150175
await pressElement(this.user, option, interactionType);
176+
if (selectionBehavior === 'replace' && interactionType !== 'touch') {
177+
await this.user.keyboard(`[/${metaKey}]`);
178+
}
151179
}
152180
}
153181
}
@@ -177,7 +205,7 @@ export class ListBoxTester {
177205
return;
178206
}
179207

180-
await this.keyboardNavigateToOption({option});
208+
await this.keyboardNavigateToOption({option, selectionOnNav: 'none'});
181209
await this.user.keyboard('[Enter]');
182210
} else {
183211
await pressElement(this.user, option, interactionType);

packages/@react-aria/test-utils/src/menu.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export class MenuTester {
215215
return;
216216
}
217217

218-
if (document.activeElement !== menu || !menu.contains(document.activeElement)) {
218+
if (document.activeElement !== menu && !menu.contains(document.activeElement)) {
219219
act(() => menu.focus());
220220
}
221221

packages/@react-aria/test-utils/src/select.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,17 @@ export class SelectTester {
3737
this.user = user;
3838
this._interactionType = interactionType || 'mouse';
3939
// Handle case where the wrapper element is provided rather than the Select's button (aka RAC)
40-
let triggerButton = within(root).queryByRole('button');
41-
if (triggerButton == null) {
40+
let buttons = within(root).queryAllByRole('button');
41+
let triggerButton;
42+
if (buttons.length === 0) {
4243
triggerButton = root;
44+
} else if (buttons.length === 1) {
45+
triggerButton = buttons[0];
46+
} else {
47+
triggerButton = buttons.find(button => button.hasAttribute('aria-haspopup'));
4348
}
44-
this._trigger = triggerButton;
49+
50+
this._trigger = triggerButton ?? root;
4551
}
4652
/**
4753
* Set the interaction type used by the select tester.
@@ -183,7 +189,7 @@ export class SelectTester {
183189
return;
184190
}
185191

186-
if (document.activeElement !== listbox || !listbox.contains(document.activeElement)) {
192+
if (document.activeElement !== listbox && !listbox.contains(document.activeElement)) {
187193
act(() => listbox.focus());
188194
}
189195
await this.keyboardNavigateToOption({option});

0 commit comments

Comments
 (0)