@react-aria/test-utils 1.0.0-alpha.7 → 1.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/combobox.main.js +5 -4
  2. package/dist/combobox.main.js.map +1 -1
  3. package/dist/combobox.mjs +5 -4
  4. package/dist/combobox.module.js +5 -4
  5. package/dist/combobox.module.js.map +1 -1
  6. package/dist/events.main.js +31 -1
  7. package/dist/events.main.js.map +1 -1
  8. package/dist/events.mjs +30 -2
  9. package/dist/events.module.js +30 -2
  10. package/dist/events.module.js.map +1 -1
  11. package/dist/gridlist.main.js +23 -9
  12. package/dist/gridlist.main.js.map +1 -1
  13. package/dist/gridlist.mjs +24 -10
  14. package/dist/gridlist.module.js +24 -10
  15. package/dist/gridlist.module.js.map +1 -1
  16. package/dist/listbox.main.js +22 -13
  17. package/dist/listbox.main.js.map +1 -1
  18. package/dist/listbox.mjs +23 -14
  19. package/dist/listbox.module.js +23 -14
  20. package/dist/listbox.module.js.map +1 -1
  21. package/dist/menu.main.js +1 -1
  22. package/dist/menu.main.js.map +1 -1
  23. package/dist/menu.mjs +1 -1
  24. package/dist/menu.module.js +1 -1
  25. package/dist/menu.module.js.map +1 -1
  26. package/dist/select.main.js +7 -4
  27. package/dist/select.main.js.map +1 -1
  28. package/dist/select.mjs +7 -4
  29. package/dist/select.module.js +7 -4
  30. package/dist/select.module.js.map +1 -1
  31. package/dist/table.main.js +42 -9
  32. package/dist/table.main.js.map +1 -1
  33. package/dist/table.mjs +44 -11
  34. package/dist/table.module.js +44 -11
  35. package/dist/table.module.js.map +1 -1
  36. package/dist/tabs.main.js +1 -1
  37. package/dist/tabs.main.js.map +1 -1
  38. package/dist/tabs.mjs +1 -1
  39. package/dist/tabs.module.js +1 -1
  40. package/dist/tabs.module.js.map +1 -1
  41. package/dist/tree.main.js +27 -8
  42. package/dist/tree.main.js.map +1 -1
  43. package/dist/tree.mjs +28 -9
  44. package/dist/tree.module.js +28 -9
  45. package/dist/tree.module.js.map +1 -1
  46. package/dist/types.d.ts +21 -1
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/userEventMaps.main.js.map +1 -1
  49. package/dist/userEventMaps.module.js.map +1 -1
  50. package/package.json +7 -3
  51. package/src/combobox.ts +10 -7
  52. package/src/events.ts +34 -2
  53. package/src/gridlist.ts +35 -12
  54. package/src/listbox.ts +47 -19
  55. package/src/menu.ts +1 -1
  56. package/src/select.ts +10 -4
  57. package/src/table.ts +71 -10
  58. package/src/tabs.ts +1 -1
  59. package/src/tree.ts +40 -10
  60. package/src/types.ts +11 -1
  61. package/src/userEventMaps.ts +1 -1
package/src/events.ts CHANGED
@@ -14,6 +14,37 @@ import {act, fireEvent} from '@testing-library/react';
14
14
  import {UserOpts} from './types';
15
15
 
16
16
  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
+ }
17
48
 
18
49
  /**
19
50
  * Simulates a "long press" event on a element.
@@ -58,9 +89,10 @@ export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer
58
89
  }
59
90
 
60
91
  // 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> {
62
93
  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}});
64
96
  } else if (interactionType === 'keyboard') {
65
97
  // 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
66
98
  // stick to simulting an actual user's keyboard operations as closely as possible
package/src/gridlist.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  */
12
12
 
13
13
  import {act, within} from '@testing-library/react';
14
+ import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events';
14
15
  import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types';
15
- import {pressElement, triggerLongPress} from './events';
16
16
 
17
17
  interface GridListToggleRowOpts extends ToggleGridRowOpts {}
18
18
  interface GridListRowActionOpts extends GridRowActionOpts {}
@@ -57,20 +57,21 @@ export class GridListTester {
57
57
  }
58
58
 
59
59
  // 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();
62
63
  let rows = this.rows;
63
64
  let targetIndex = rows.indexOf(row);
64
65
  if (targetIndex === -1) {
65
66
  throw new Error('Option provided is not in the gridlist');
66
67
  }
67
68
 
68
- if (document.activeElement !== this._gridlist || !this._gridlist.contains(document.activeElement)) {
69
+ if (document.activeElement !== this._gridlist && !this._gridlist.contains(document.activeElement)) {
69
70
  act(() => this._gridlist.focus());
70
71
  }
71
72
 
72
73
  if (document.activeElement === this._gridlist) {
73
- await this.user.keyboard('[ArrowDown]');
74
+ await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`);
74
75
  } else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') {
75
76
  do {
76
77
  await this.user.keyboard('[ArrowLeft]');
@@ -82,22 +83,33 @@ export class GridListTester {
82
83
  }
83
84
  let direction = targetIndex > currIndex ? 'down' : 'up';
84
85
 
86
+ if (selectionOnNav === 'none') {
87
+ await this.user.keyboard(`[${altKey}>]`);
88
+ }
85
89
  for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
86
90
  await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
87
91
  }
92
+ if (selectionOnNav === 'none') {
93
+ await this.user.keyboard(`[/${altKey}]`);
94
+ }
88
95
  };
89
96
 
90
97
  /**
91
98
  * 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.
92
100
  */
93
101
  async toggleRowSelection(opts: GridListToggleRowOpts): Promise<void> {
94
102
  let {
95
103
  row,
96
104
  needsLongPress,
97
105
  checkboxSelection = true,
98
- interactionType = this._interactionType
106
+ interactionType = this._interactionType,
107
+ selectionBehavior = 'toggle'
99
108
  } = opts;
100
109
 
110
+ let altKey = getAltKey();
111
+ let metaKey = getMetaKey();
112
+
101
113
  if (typeof row === 'string' || typeof row === 'number') {
102
114
  row = this.findRow({rowIndexOrText: row});
103
115
  }
@@ -116,9 +128,15 @@ export class GridListTester {
116
128
 
117
129
  // this would be better than the check to do nothing in events.ts
118
130
  // 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
+ }
122
140
  return;
123
141
  }
124
142
  if (rowCheckbox && checkboxSelection) {
@@ -132,9 +150,14 @@ export class GridListTester {
132
150
 
133
151
  // Note that long press interactions with rows is strictly touch only for grid rows
134
152
  await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}});
135
-
136
153
  } 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
+ }
138
161
  }
139
162
  }
140
163
  }
@@ -166,7 +189,7 @@ export class GridListTester {
166
189
  return;
167
190
  }
168
191
 
169
- await this.keyboardNavigateToRow({row});
192
+ await this.keyboardNavigateToRow({row, selectionOnNav: 'none'});
170
193
  await this.user.keyboard('[Enter]');
171
194
  } else {
172
195
  await pressElement(this.user, row, interactionType);
package/src/listbox.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  */
12
12
 
13
13
  import {act, within} from '@testing-library/react';
14
+ import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events';
14
15
  import {ListBoxTesterOpts, UserOpts} from './types';
15
- import {pressElement, triggerLongPress} from './events';
16
16
 
17
17
  interface ListBoxToggleOptionOpts {
18
18
  /**
@@ -31,7 +31,16 @@ interface ListBoxToggleOptionOpts {
31
31
  /**
32
32
  * Whether the option needs to be long pressed to be selected. Depends on the listbox's implementation.
33
33
  */
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'
35
44
  }
36
45
 
37
46
  interface ListBoxOptionActionOpts extends Omit<ListBoxToggleOptionOpts, 'keyboardActivation' | 'needsLongPress'> {
@@ -85,44 +94,51 @@ export class ListBoxTester {
85
94
 
86
95
  // TODO: this is basically the same as menu except for the error message, refactor later so that they share
87
96
  // 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();
90
100
  let options = this.options();
91
101
  let targetIndex = options.indexOf(option);
92
102
  if (targetIndex === -1) {
93
103
  throw new Error('Option provided is not in the listbox');
94
104
  }
95
105
 
96
- if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) {
106
+ if (document.activeElement !== this._listbox && !this._listbox.contains(document.activeElement)) {
97
107
  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}]` : ''}`);
108
109
  }
109
110
 
110
111
  let currIndex = options.indexOf(document.activeElement as HTMLElement);
111
112
  if (currIndex === -1) {
112
113
  throw new Error('ActiveElement is not in the listbox');
113
114
  }
114
- let direction = targetIndex > currIndex ? 'down' : 'up';
115
115
 
116
+ let direction = targetIndex > currIndex ? 'down' : 'up';
117
+ if (selectionOnNav === 'none') {
118
+ await this.user.keyboard(`[${altKey}>]`);
119
+ }
116
120
  for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
117
121
  await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
118
122
  }
123
+ if (selectionOnNav === 'none') {
124
+ await this.user.keyboard(`[/${altKey}]`);
125
+ }
119
126
  };
120
127
 
121
128
  /**
122
129
  * Toggles the selection for the specified listbox option. Defaults to using the interaction type set on the listbox tester.
123
130
  */
124
131
  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();
126
142
 
127
143
  if (typeof option === 'string' || typeof option === 'number') {
128
144
  option = this.findOption({optionIndexOrText: option});
@@ -137,8 +153,14 @@ export class ListBoxTester {
137
153
  return;
138
154
  }
139
155
 
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
+ }
141
160
  await this.user.keyboard(`[${keyboardActivation}]`);
161
+ if (selectionBehavior === 'replace') {
162
+ await this.user.keyboard(`[/${altKey}]`);
163
+ }
142
164
  } else {
143
165
  if (needsLongPress && interactionType === 'touch') {
144
166
  if (this._advanceTimer == null) {
@@ -147,7 +169,13 @@ export class ListBoxTester {
147
169
 
148
170
  await triggerLongPress({element: option, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}});
149
171
  } else {
172
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
173
+ await this.user.keyboard(`[${metaKey}>]`);
174
+ }
150
175
  await pressElement(this.user, option, interactionType);
176
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
177
+ await this.user.keyboard(`[/${metaKey}]`);
178
+ }
151
179
  }
152
180
  }
153
181
  }
@@ -177,7 +205,7 @@ export class ListBoxTester {
177
205
  return;
178
206
  }
179
207
 
180
- await this.keyboardNavigateToOption({option});
208
+ await this.keyboardNavigateToOption({option, selectionOnNav: 'none'});
181
209
  await this.user.keyboard('[Enter]');
182
210
  } else {
183
211
  await pressElement(this.user, option, interactionType);
package/src/menu.ts CHANGED
@@ -215,7 +215,7 @@ export class MenuTester {
215
215
  return;
216
216
  }
217
217
 
218
- if (document.activeElement !== menu || !menu.contains(document.activeElement)) {
218
+ if (document.activeElement !== menu && !menu.contains(document.activeElement)) {
219
219
  act(() => menu.focus());
220
220
  }
221
221
 
package/src/select.ts CHANGED
@@ -37,11 +37,17 @@ export class SelectTester {
37
37
  this.user = user;
38
38
  this._interactionType = interactionType || 'mouse';
39
39
  // 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) {
42
43
  triggerButton = root;
44
+ } else if (buttons.length === 1) {
45
+ triggerButton = buttons[0];
46
+ } else {
47
+ triggerButton = buttons.find(button => button.hasAttribute('aria-haspopup'));
43
48
  }
44
- this._trigger = triggerButton;
49
+
50
+ this._trigger = triggerButton ?? root;
45
51
  }
46
52
  /**
47
53
  * Set the interaction type used by the select tester.
@@ -183,7 +189,7 @@ export class SelectTester {
183
189
  return;
184
190
  }
185
191
 
186
- if (document.activeElement !== listbox || !listbox.contains(document.activeElement)) {
192
+ if (document.activeElement !== listbox && !listbox.contains(document.activeElement)) {
187
193
  act(() => listbox.focus());
188
194
  }
189
195
  await this.keyboardNavigateToOption({option});
package/src/table.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  */
12
12
 
13
13
  import {act, waitFor, within} from '@testing-library/react';
14
+ import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events';
14
15
  import {GridRowActionOpts, TableTesterOpts, ToggleGridRowOpts, UserOpts} from './types';
15
- import {pressElement, triggerLongPress} from './events';
16
16
 
17
17
  interface TableToggleRowOpts extends ToggleGridRowOpts {}
18
18
  interface TableToggleSortOpts {
@@ -54,6 +54,55 @@ export class TableTester {
54
54
  this._interactionType = type;
55
55
  }
56
56
 
57
+ // TODO: RTL
58
+ private async keyboardNavigateToRow(opts: {row: HTMLElement, selectionOnNav?: 'default' | 'none'}) {
59
+ let {row, selectionOnNav = 'default'} = opts;
60
+ let altKey = getAltKey();
61
+ let rows = this.rows;
62
+ let targetIndex = rows.indexOf(row);
63
+ if (targetIndex === -1) {
64
+ throw new Error('Row provided is not in the table');
65
+ }
66
+
67
+ // Move focus into the table
68
+ if (document.activeElement !== this._table && !this._table.contains(document.activeElement)) {
69
+ act(() => this._table.focus());
70
+ }
71
+
72
+ if (document.activeElement === this._table) {
73
+ await this.user.keyboard('[ArrowDown]');
74
+ }
75
+
76
+ // 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
77
+ if (this.rowGroups[0].contains(document.activeElement)) {
78
+ do {
79
+ await this.user.keyboard('[ArrowDown]');
80
+ } while (!this.rowGroups[1].contains(document.activeElement));
81
+ }
82
+
83
+ // Move focus onto the row itself
84
+ if (this.rowGroups[1].contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') {
85
+ do {
86
+ await this.user.keyboard('[ArrowLeft]');
87
+ } while (document.activeElement!.getAttribute('role') !== 'row');
88
+ }
89
+ let currIndex = rows.indexOf(document.activeElement as HTMLElement);
90
+ if (currIndex === -1) {
91
+ throw new Error('Current active element is not on any of the table rows');
92
+ }
93
+ let direction = targetIndex > currIndex ? 'down' : 'up';
94
+
95
+ if (selectionOnNav === 'none') {
96
+ await this.user.keyboard(`[${altKey}>]`);
97
+ }
98
+ for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
99
+ await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
100
+ }
101
+ if (selectionOnNav === 'none') {
102
+ await this.user.keyboard(`[/${altKey}]`);
103
+ }
104
+ };
105
+
57
106
  /**
58
107
  * Toggles the selection for the specified table row. Defaults to using the interaction type set on the table tester.
59
108
  */
@@ -62,9 +111,13 @@ export class TableTester {
62
111
  row,
63
112
  needsLongPress,
64
113
  checkboxSelection = true,
65
- interactionType = this._interactionType
114
+ interactionType = this._interactionType,
115
+ selectionBehavior = 'toggle'
66
116
  } = opts;
67
117
 
118
+ let altKey = getMetaKey();
119
+ let metaKey = getMetaKey();
120
+
68
121
  if (typeof row === 'string' || typeof row === 'number') {
69
122
  row = this.findRow({rowIndexOrText: row});
70
123
  }
@@ -75,12 +128,15 @@ export class TableTester {
75
128
 
76
129
  let rowCheckbox = within(row).queryByRole('checkbox');
77
130
 
78
- if (interactionType === 'keyboard' && !checkboxSelection) {
79
- // TODO: for now focus the row directly until I add keyboard navigation
80
- await act(async () => {
81
- row.focus();
82
- });
83
- 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
+ }
84
140
  return;
85
141
  }
86
142
  if (rowCheckbox && checkboxSelection) {
@@ -95,7 +151,13 @@ export class TableTester {
95
151
  // Note that long press interactions with rows is strictly touch only for grid rows
96
152
  await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}});
97
153
  } else {
154
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
155
+ await this.user.keyboard(`[${metaKey}>]`);
156
+ }
98
157
  await pressElement(this.user, cell, interactionType);
158
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
159
+ await this.user.keyboard(`[/${metaKey}]`);
160
+ }
99
161
  }
100
162
  }
101
163
  };
@@ -297,8 +359,7 @@ export class TableTester {
297
359
  if (needsDoubleClick) {
298
360
  await this.user.dblClick(row);
299
361
  } else if (interactionType === 'keyboard') {
300
- // TODO: add keyboard navigation instead of focusing the row directly. Will need to consider if the focus in in the columns
301
- act(() => row.focus());
362
+ await this.keyboardNavigateToRow({row, selectionOnNav: 'none'});
302
363
  await this.user.keyboard('[Enter]');
303
364
  } else {
304
365
  await pressElement(this.user, row, interactionType);
package/src/tabs.ts CHANGED
@@ -137,7 +137,7 @@ export class TabsTester {
137
137
  }
138
138
 
139
139
  if (interactionType === 'keyboard') {
140
- if (document.activeElement !== this._tablist || !this._tablist.contains(document.activeElement)) {
140
+ if (document.activeElement !== this._tablist && !this._tablist.contains(document.activeElement)) {
141
141
  act(() => this._tablist.focus());
142
142
  }
143
143
 
package/src/tree.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import {act, within} from '@testing-library/react';
14
14
  import {BaseGridRowInteractionOpts, GridRowActionOpts, ToggleGridRowOpts, TreeTesterOpts, UserOpts} from './types';
15
- import {pressElement, triggerLongPress} from './events';
15
+ import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events';
16
16
 
17
17
  interface TreeToggleExpansionOpts extends BaseGridRowInteractionOpts {}
18
18
  interface TreeToggleRowOpts extends ToggleGridRowOpts {}
@@ -64,20 +64,21 @@ export class TreeTester {
64
64
  }
65
65
 
66
66
  // TODO: RTL
67
- private async keyboardNavigateToRow(opts: {row: HTMLElement}) {
68
- let {row} = opts;
67
+ private async keyboardNavigateToRow(opts: {row: HTMLElement, selectionOnNav?: 'default' | 'none'}) {
68
+ let {row, selectionOnNav = 'default'} = opts;
69
+ let altKey = getAltKey();
69
70
  let rows = this.rows;
70
71
  let targetIndex = rows.indexOf(row);
71
72
  if (targetIndex === -1) {
72
73
  throw new Error('Option provided is not in the tree');
73
74
  }
74
75
 
75
- if (document.activeElement !== this._tree || !this._tree.contains(document.activeElement)) {
76
+ if (document.activeElement !== this._tree && !this._tree.contains(document.activeElement)) {
76
77
  act(() => this._tree.focus());
77
78
  }
78
79
 
79
80
  if (document.activeElement === this.tree) {
80
- await this.user.keyboard('[ArrowDown]');
81
+ await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`);
81
82
  } else if (this._tree.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') {
82
83
  do {
83
84
  await this.user.keyboard('[ArrowLeft]');
@@ -89,22 +90,33 @@ export class TreeTester {
89
90
  }
90
91
  let direction = targetIndex > currIndex ? 'down' : 'up';
91
92
 
93
+ if (selectionOnNav === 'none') {
94
+ await this.user.keyboard(`[${altKey}>]`);
95
+ }
92
96
  for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
93
97
  await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
94
98
  }
99
+ if (selectionOnNav === 'none') {
100
+ await this.user.keyboard(`[/${altKey}]`);
101
+ }
95
102
  };
96
103
 
97
104
  /**
98
105
  * Toggles the selection for the specified tree row. Defaults to using the interaction type set on the tree tester.
106
+ * Note that this will endevor to always add/remove JUST the provided row to the set of selected rows.
99
107
  */
100
108
  async toggleRowSelection(opts: TreeToggleRowOpts): Promise<void> {
101
109
  let {
102
110
  row,
103
111
  needsLongPress,
104
112
  checkboxSelection = true,
105
- interactionType = this._interactionType
113
+ interactionType = this._interactionType,
114
+ selectionBehavior = 'toggle'
106
115
  } = opts;
107
116
 
117
+ let altKey = getAltKey();
118
+ let metaKey = getMetaKey();
119
+
108
120
  if (typeof row === 'string' || typeof row === 'number') {
109
121
  row = this.findRow({rowIndexOrText: row});
110
122
  }
@@ -123,9 +135,15 @@ export class TreeTester {
123
135
 
124
136
  // this would be better than the check to do nothing in events.ts
125
137
  // also, it'd be good to be able to trigger selection on the row instead of having to go to the checkbox directly
126
- if (interactionType === 'keyboard' && !checkboxSelection) {
127
- await this.keyboardNavigateToRow({row});
128
- await this.user.keyboard('{Space}');
138
+ if (interactionType === 'keyboard' && (!checkboxSelection || !rowCheckbox)) {
139
+ await this.keyboardNavigateToRow({row, selectionOnNav: selectionBehavior === 'replace' ? 'none' : 'default'});
140
+ if (selectionBehavior === 'replace') {
141
+ await this.user.keyboard(`[${altKey}>]`);
142
+ }
143
+ await this.user.keyboard('[Space]');
144
+ if (selectionBehavior === 'replace') {
145
+ await this.user.keyboard(`[/${altKey}]`);
146
+ }
129
147
  return;
130
148
  }
131
149
  if (rowCheckbox && checkboxSelection) {
@@ -140,7 +158,14 @@ export class TreeTester {
140
158
  // Note that long press interactions with rows is strictly touch only for grid rows
141
159
  await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}});
142
160
  } else {
143
- await pressElement(this.user, cell, interactionType);
161
+ // TODO add modifiers here? Maybe move into pressElement if we get more cases for different types of modifier keys
162
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
163
+ await this.user.keyboard(`[${metaKey}>]`);
164
+ }
165
+ await pressElement(this.user, row, interactionType);
166
+ if (selectionBehavior === 'replace' && interactionType !== 'touch') {
167
+ await this.user.keyboard(`[/${metaKey}]`);
168
+ }
144
169
  }
145
170
  }
146
171
  };
@@ -177,6 +202,9 @@ export class TreeTester {
177
202
  return;
178
203
  }
179
204
 
205
+ // TODO: We always Use Option/Ctrl when keyboard navigating so selection isn't changed
206
+ // in selectionmode="replace"/highlight selection when navigating to the row that the user wants
207
+ // to expand. Discuss if this is useful or not
180
208
  await this.keyboardNavigateToRow({row});
181
209
  if (row.getAttribute('aria-expanded') === 'true') {
182
210
  await this.user.keyboard('[ArrowLeft]');
@@ -211,6 +239,8 @@ export class TreeTester {
211
239
  return;
212
240
  }
213
241
 
242
+ // TODO: same as above, uses the modifier key to make sure we don't modify selection state on row focus
243
+ // as we keyboard navigate to the row we want activate
214
244
  await this.keyboardNavigateToRow({row});
215
245
  await this.user.keyboard('[Enter]');
216
246
  } else {
package/src/types.ts CHANGED
@@ -126,7 +126,17 @@ export interface ToggleGridRowOpts extends BaseGridRowInteractionOpts {
126
126
  * Whether the checkbox should be used to select the row. If false, will attempt to select the row via press.
127
127
  * @default 'true'
128
128
  */
129
- checkboxSelection?: boolean
129
+ checkboxSelection?: boolean,
130
+ // TODO: this api feels a bit confusing tbh...
131
+ /**
132
+ * Whether the grid has a selectionBehavior of "toggle" or "replace" (aka highlight selection). This affects the user operations
133
+ * required to toggle row selection by adding modifier keys during user actions, useful when performing multi-row selection in a "selectionBehavior: 'replace'" grid.
134
+ * If you would like to still simulate user actions (aka press) without these modifiers keys for a "selectionBehavior: replace" grid, simply omit this option.
135
+ * 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).
136
+ *
137
+ * @default 'toggle'
138
+ */
139
+ selectionBehavior?: 'toggle' | 'replace'
130
140
  }
131
141
 
132
142
  export interface GridRowActionOpts extends BaseGridRowInteractionOpts {
@@ -1,4 +1,4 @@
1
- import {pointerKey} from '@testing-library/user-event/system/pointer/shared';
1
+ import {pointerKey} from '@testing-library/user-event';
2
2
 
3
3
  export let pointerMap: pointerKey[] = [
4
4
  {name: 'MouseLeft', pointerType: 'mouse', button: 'primary', height: 1, width: 1, pressure: 0.5},