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

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 (55) hide show
  1. package/dist/combobox.main.js.map +1 -1
  2. package/dist/combobox.module.js.map +1 -1
  3. package/dist/events.main.js.map +1 -1
  4. package/dist/events.module.js.map +1 -1
  5. package/dist/gridlist.main.js +1 -1
  6. package/dist/gridlist.main.js.map +1 -1
  7. package/dist/gridlist.mjs +1 -1
  8. package/dist/gridlist.module.js +1 -1
  9. package/dist/gridlist.module.js.map +1 -1
  10. package/dist/listbox.main.js +2 -3
  11. package/dist/listbox.main.js.map +1 -1
  12. package/dist/listbox.mjs +2 -3
  13. package/dist/listbox.module.js +2 -3
  14. package/dist/listbox.module.js.map +1 -1
  15. package/dist/menu.main.js +39 -19
  16. package/dist/menu.main.js.map +1 -1
  17. package/dist/menu.mjs +39 -19
  18. package/dist/menu.module.js +39 -19
  19. package/dist/menu.module.js.map +1 -1
  20. package/dist/select.main.js.map +1 -1
  21. package/dist/select.module.js.map +1 -1
  22. package/dist/table.main.js +48 -0
  23. package/dist/table.main.js.map +1 -1
  24. package/dist/table.mjs +48 -0
  25. package/dist/table.module.js +48 -0
  26. package/dist/table.module.js.map +1 -1
  27. package/dist/tabs.main.js.map +1 -1
  28. package/dist/tabs.module.js.map +1 -1
  29. package/dist/testSetup.main.js.map +1 -1
  30. package/dist/testSetup.module.js.map +1 -1
  31. package/dist/tree.main.js +1 -1
  32. package/dist/tree.main.js.map +1 -1
  33. package/dist/tree.mjs +1 -1
  34. package/dist/tree.module.js +1 -1
  35. package/dist/tree.module.js.map +1 -1
  36. package/dist/types.d.ts +16 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/user.main.js +1 -1
  39. package/dist/user.main.js.map +1 -1
  40. package/dist/user.mjs +1 -1
  41. package/dist/user.module.js +1 -1
  42. package/dist/user.module.js.map +1 -1
  43. package/package.json +5 -4
  44. package/src/combobox.ts +4 -4
  45. package/src/events.ts +3 -3
  46. package/src/gridlist.ts +8 -7
  47. package/src/listbox.ts +7 -13
  48. package/src/menu.ts +64 -14
  49. package/src/select.ts +4 -4
  50. package/src/table.ts +98 -6
  51. package/src/tabs.ts +2 -2
  52. package/src/testSetup.ts +2 -2
  53. package/src/tree.ts +9 -8
  54. package/src/types.ts +8 -4
  55. package/src/user.ts +1 -1
package/src/menu.ts CHANGED
@@ -64,9 +64,10 @@ export class MenuTester {
64
64
  private _advanceTimer: UserOpts['advanceTimer'];
65
65
  private _trigger: HTMLElement | undefined;
66
66
  private _isSubmenu: boolean = false;
67
+ private _rootMenu: HTMLElement | undefined;
67
68
 
68
69
  constructor(opts: MenuTesterOpts) {
69
- let {root, user, interactionType, advanceTimer, isSubmenu} = opts;
70
+ let {root, user, interactionType, advanceTimer, isSubmenu, rootMenu} = opts;
70
71
  this.user = user;
71
72
  this._interactionType = interactionType || 'mouse';
72
73
  this._advanceTimer = advanceTimer;
@@ -85,12 +86,13 @@ export class MenuTester {
85
86
  }
86
87
 
87
88
  this._isSubmenu = isSubmenu || false;
89
+ this._rootMenu = rootMenu;
88
90
  }
89
91
 
90
92
  /**
91
93
  * Set the interaction type used by the menu tester.
92
94
  */
93
- setInteractionType(type: UserOpts['interactionType']) {
95
+ setInteractionType(type: UserOpts['interactionType']): void {
94
96
  this._interactionType = type;
95
97
  }
96
98
 
@@ -99,7 +101,7 @@ export class MenuTester {
99
101
  /**
100
102
  * Opens the menu. Defaults to using the interaction type set on the menu tester.
101
103
  */
102
- async open(opts: MenuOpenOpts = {}) {
104
+ async open(opts: MenuOpenOpts = {}): Promise<void> {
103
105
  let {
104
106
  needsLongPress,
105
107
  interactionType = this._interactionType,
@@ -178,7 +180,7 @@ export class MenuTester {
178
180
  * Selects the desired menu option. Defaults to using the interaction type set on the menu tester. If necessary, will open the menu dropdown beforehand.
179
181
  * The desired option can be targeted via the option's node, the option's text, or the option's index.
180
182
  */
181
- async selectOption(opts: MenuSelectOpts) {
183
+ async selectOption(opts: MenuSelectOpts): Promise<void> {
182
184
  let {
183
185
  menuSelectionMode = 'single',
184
186
  needsLongPress,
@@ -226,20 +228,56 @@ export class MenuTester {
226
228
  await this.user.pointer({target: option, keys: '[TouchA]'});
227
229
  }
228
230
  }
229
- act(() => {jest.runAllTimers();});
230
231
 
231
- if (option.getAttribute('href') == null && option.getAttribute('aria-haspopup') == null && menuSelectionMode === 'single' && closesOnSelect && keyboardActivation !== 'Space' && !this._isSubmenu) {
232
+ // This chain of waitFors is needed in place of running all timers since we don't know how long transitions may take, or what action
233
+ // the menu option select may trigger.
234
+ if (
235
+ !(menuSelectionMode === 'single' && !closesOnSelect) &&
236
+ !(menuSelectionMode === 'multiple' && (keyboardActivation === 'Space' || interactionType === 'mouse'))
237
+ ) {
238
+ // For RSP, clicking on a submenu option seems to briefly lose focus to the body before moving to the clicked option in the test so we need to wait
239
+ // for focus to be coerced to somewhere else in place of running all timers.
240
+ if (this._isSubmenu) {
241
+ await waitFor(() => {
242
+ if (document.activeElement === document.body) {
243
+ throw new Error('Expected focus to move to somewhere other than the body after selecting a submenu option.');
244
+ } else {
245
+ return true;
246
+ }
247
+ });
248
+ }
249
+
250
+ // If user isn't trying to select multiple menu options or closeOnSelect is true then we can assume that
251
+ // the menu will close or some action is triggered. In cases like that focus should move somewhere after the menu closes
252
+ // but we can't really know where so just make sure it doesn't get lost to the body.
232
253
  await waitFor(() => {
233
- if (document.activeElement !== trigger) {
234
- throw new Error(`Expected the document.activeElement after selecting an option to be the menu trigger but got ${document.activeElement}`);
254
+ if (document.activeElement === option) {
255
+ throw new Error('Expected focus after selecting an option to move away from the option.');
235
256
  } else {
236
257
  return true;
237
258
  }
238
259
  });
239
260
 
240
- if (document.contains(menu)) {
241
- throw new Error('Expected menu element to not be in the document after selecting an option');
261
+ // We'll also want to wait for focus to move away from the original submenu trigger since the entire submenu tree should
262
+ // close. In React 16, focus actually makes it all the way to the root menu's submenu trigger so we need check the root menu
263
+ if (this._isSubmenu) {
264
+ await waitFor(() => {
265
+ if (document.activeElement === this.trigger || this._rootMenu?.contains(document.activeElement)) {
266
+ throw new Error('Expected focus after selecting an submenu option to move away from the original submenu trigger.');
267
+ } else {
268
+ return true;
269
+ }
270
+ });
242
271
  }
272
+
273
+ // Finally wait for focus to be coerced somewhere final when the menu tree is removed from the DOM
274
+ await waitFor(() => {
275
+ if (document.activeElement === document.body) {
276
+ throw new Error('Expected focus to move to somewhere other than the body after selecting a menu option.');
277
+ } else {
278
+ return true;
279
+ }
280
+ });
243
281
  }
244
282
  } else {
245
283
  throw new Error("Attempted to select a option in the menu, but menu wasn't found.");
@@ -269,18 +307,30 @@ export class MenuTester {
269
307
  submenuTrigger = (within(menu!).getByText(submenuTrigger).closest('[role=menuitem]'))! as HTMLElement;
270
308
  }
271
309
 
272
- let submenuTriggerTester = new MenuTester({user: this.user, interactionType: this._interactionType, root: submenuTrigger, isSubmenu: true});
310
+ let submenuTriggerTester = new MenuTester({
311
+ user: this.user,
312
+ interactionType: this._interactionType,
313
+ root: submenuTrigger,
314
+ isSubmenu: true,
315
+ advanceTimer: this._advanceTimer,
316
+ rootMenu: (this._isSubmenu ? this._rootMenu : this.menu) || undefined
317
+ });
273
318
  if (interactionType === 'mouse') {
274
319
  await this.user.pointer({target: submenuTrigger});
275
- act(() => {jest.runAllTimers();});
276
320
  } else if (interactionType === 'keyboard') {
277
321
  await this.keyboardNavigateToOption({option: submenuTrigger});
278
322
  await this.user.keyboard('[ArrowRight]');
279
- act(() => {jest.runAllTimers();});
280
323
  } else {
281
324
  await submenuTriggerTester.open();
282
325
  }
283
326
 
327
+ await waitFor(() => {
328
+ if (submenuTriggerTester._trigger?.getAttribute('aria-expanded') !== 'true') {
329
+ throw new Error('aria-expanded for the submenu trigger wasn\'t changed to "true", unable to confirm the existance of the submenu');
330
+ } else {
331
+ return true;
332
+ }
333
+ });
284
334
 
285
335
  return submenuTriggerTester;
286
336
  }
@@ -314,7 +364,7 @@ export class MenuTester {
314
364
  /**
315
365
  * Closes the menu.
316
366
  */
317
- async close() {
367
+ async close(): Promise<void> {
318
368
  let menu = this.menu;
319
369
  if (menu) {
320
370
  act(() => menu.focus());
package/src/select.ts CHANGED
@@ -46,14 +46,14 @@ export class SelectTester {
46
46
  /**
47
47
  * Set the interaction type used by the select tester.
48
48
  */
49
- setInteractionType(type: UserOpts['interactionType']) {
49
+ setInteractionType(type: UserOpts['interactionType']): void {
50
50
  this._interactionType = type;
51
51
  }
52
52
 
53
53
  /**
54
54
  * Opens the select. Defaults to using the interaction type set on the select tester.
55
55
  */
56
- async open(opts: SelectOpenOpts = {}) {
56
+ async open(opts: SelectOpenOpts = {}): Promise<void> {
57
57
  let {
58
58
  interactionType = this._interactionType
59
59
  } = opts;
@@ -89,7 +89,7 @@ export class SelectTester {
89
89
  /**
90
90
  * Closes the select.
91
91
  */
92
- async close() {
92
+ async close(): Promise<void> {
93
93
  let listbox = this.listbox;
94
94
  if (listbox) {
95
95
  act(() => listbox.focus());
@@ -155,7 +155,7 @@ export class SelectTester {
155
155
  * Selects the desired select option. Defaults to using the interaction type set on the select tester. If necessary, will open the select dropdown beforehand.
156
156
  * The desired option can be targeted via the option's node, the option's text, or the option's index.
157
157
  */
158
- async selectOption(opts: SelectTriggerOptionOpts) {
158
+ async selectOption(opts: SelectTriggerOptionOpts): Promise<void> {
159
159
  let {
160
160
  option,
161
161
  interactionType = this._interactionType
package/src/table.ts CHANGED
@@ -25,6 +25,12 @@ interface TableToggleSortOpts {
25
25
  */
26
26
  interactionType?: UserOpts['interactionType']
27
27
  }
28
+ interface TableColumnHeaderActionOpts extends TableToggleSortOpts {
29
+ /**
30
+ * The index of the column header action to trigger.
31
+ */
32
+ action: number
33
+ }
28
34
  interface TableRowActionOpts extends GridRowActionOpts {}
29
35
 
30
36
  export class TableTester {
@@ -44,14 +50,14 @@ export class TableTester {
44
50
  /**
45
51
  * Set the interaction type used by the table tester.
46
52
  */
47
- setInteractionType(type: UserOpts['interactionType']) {
53
+ setInteractionType(type: UserOpts['interactionType']): void {
48
54
  this._interactionType = type;
49
55
  }
50
56
 
51
57
  /**
52
58
  * Toggles the selection for the specified table row. Defaults to using the interaction type set on the table tester.
53
59
  */
54
- async toggleRowSelection(opts: TableToggleRowOpts) {
60
+ async toggleRowSelection(opts: TableToggleRowOpts): Promise<void> {
55
61
  let {
56
62
  row,
57
63
  needsLongPress,
@@ -97,7 +103,7 @@ export class TableTester {
97
103
  /**
98
104
  * Toggles the sort order for the specified table column. Defaults to using the interaction type set on the table tester.
99
105
  */
100
- async toggleSort(opts: TableToggleSortOpts) {
106
+ async toggleSort(opts: TableToggleSortOpts): Promise<void> {
101
107
  let {
102
108
  column,
103
109
  interactionType = this._interactionType
@@ -184,10 +190,96 @@ export class TableTester {
184
190
  }
185
191
  }
186
192
 
193
+ /**
194
+ * Triggers an action for the specified table column menu. Defaults to using the interaction type set on the table tester.
195
+ */
196
+ async triggerColumnHeaderAction(opts: TableColumnHeaderActionOpts): Promise<void> {
197
+ let {
198
+ column,
199
+ interactionType = this._interactionType,
200
+ action
201
+ } = opts;
202
+
203
+ let columnheader;
204
+ if (typeof column === 'number') {
205
+ columnheader = this.columns[column];
206
+ } else if (typeof column === 'string') {
207
+ columnheader = within(this.rowGroups[0]).getByText(column);
208
+ while (columnheader && !/columnheader/.test(columnheader.getAttribute('role'))) {
209
+ columnheader = columnheader.parentElement;
210
+ }
211
+ } else {
212
+ columnheader = column;
213
+ }
214
+
215
+ let menuButton = within(columnheader).queryByRole('button');
216
+ if (menuButton) {
217
+ // TODO: Focus management is all kinda of messed up if I just use .focus and Space to open the sort menu. Seems like
218
+ // the focused key doesn't get properly set to the desired column header. Have to do this strange flow where I focus the
219
+ // column header except if the active element is already the menu button within the column header
220
+ if (interactionType === 'keyboard' && document.activeElement !== menuButton) {
221
+ await pressElement(this.user, columnheader, interactionType);
222
+ } else {
223
+ await pressElement(this.user, menuButton, interactionType);
224
+ }
225
+
226
+ await waitFor(() => {
227
+ if (menuButton.getAttribute('aria-controls') == null) {
228
+ throw new Error('No aria-controls found on table column dropdown menu trigger element.');
229
+ } else {
230
+ return true;
231
+ }
232
+ });
233
+
234
+ let menuId = menuButton.getAttribute('aria-controls');
235
+ await waitFor(() => {
236
+ if (!menuId || document.getElementById(menuId) == null) {
237
+ throw new Error(`Table column header menu with id of ${menuId} not found in document.`);
238
+ } else {
239
+ return true;
240
+ }
241
+ });
242
+
243
+ if (menuId) {
244
+ let menu = document.getElementById(menuId);
245
+ if (menu) {
246
+ await pressElement(this.user, within(menu).getAllByRole('menuitem')[action], interactionType);
247
+
248
+ await waitFor(() => {
249
+ if (document.contains(menu)) {
250
+ throw new Error('Expected table column menu listbox to not be in the document after selecting an option');
251
+ } else {
252
+ return true;
253
+ }
254
+ });
255
+ }
256
+ }
257
+
258
+ // Handle cases where the table may transition in response to the row selection/deselection
259
+ if (!this._advanceTimer) {
260
+ throw new Error('No advanceTimers provided for table transition.');
261
+ }
262
+
263
+ await act(async () => {
264
+ await this._advanceTimer?.(200);
265
+ });
266
+
267
+ await waitFor(() => {
268
+ if (document.activeElement !== menuButton) {
269
+ throw new Error(`Expected the document.activeElement to be the table column menu button but got ${document.activeElement}`);
270
+ } else {
271
+ return true;
272
+ }
273
+ });
274
+ } else {
275
+ throw new Error('No menu button found on table column header.');
276
+ }
277
+ }
278
+
187
279
  /**
188
280
  * Triggers the action for the specified table row. Defaults to using the interaction type set on the table tester.
189
281
  */
190
- async triggerRowAction(opts: TableRowActionOpts) {
282
+ async triggerRowAction(opts: TableRowActionOpts): Promise<void> {
191
283
  let {
192
284
  row,
193
285
  needsDoubleClick,
@@ -221,7 +313,7 @@ export class TableTester {
221
313
  /**
222
314
  * Toggle selection for all rows in the table. Defaults to using the interaction type set on the table tester.
223
315
  */
224
- async toggleSelectAll(opts: {interactionType?: UserOpts['interactionType']} = {}) {
316
+ async toggleSelectAll(opts: {interactionType?: UserOpts['interactionType']} = {}): Promise<void> {
225
317
  let {
226
318
  interactionType = this._interactionType
227
319
  } = opts;
@@ -260,7 +352,7 @@ export class TableTester {
260
352
  /**
261
353
  * Returns a cell matching the specified text content.
262
354
  */
263
- findCell(opts: {text: string}) {
355
+ findCell(opts: {text: string}): HTMLElement {
264
356
  let {
265
357
  text
266
358
  } = opts;
package/src/tabs.ts CHANGED
@@ -51,7 +51,7 @@ export class TabsTester {
51
51
  /**
52
52
  * Set the interaction type used by the tabs tester.
53
53
  */
54
- setInteractionType(type: UserOpts['interactionType']) {
54
+ setInteractionType(type: UserOpts['interactionType']): void {
55
55
  this._interactionType = type;
56
56
  }
57
57
 
@@ -119,7 +119,7 @@ export class TabsTester {
119
119
  /**
120
120
  * Triggers the specified tab. Defaults to using the interaction type set on the tabs tester.
121
121
  */
122
- async triggerTab(opts: TriggerTabOptions) {
122
+ async triggerTab(opts: TriggerTabOptions): Promise<void> {
123
123
  let {
124
124
  tab,
125
125
  interactionType = this._interactionType,
package/src/testSetup.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  /**
14
14
  * Enables reading pageX/pageY from fireEvent.mouse*(..., {pageX: ..., pageY: ...}).
15
15
  */
16
- export function installMouseEvent() {
16
+ export function installMouseEvent(): void {
17
17
  let oldMouseEvent = MouseEvent;
18
18
  beforeAll(() => {
19
19
  global.MouseEvent = class FakeMouseEvent extends MouseEvent {
@@ -35,7 +35,7 @@ export function installMouseEvent() {
35
35
  });
36
36
  }
37
37
 
38
- export function installPointerEvent() {
38
+ export function installPointerEvent(): void {
39
39
  beforeAll(() => {
40
40
  // @ts-ignore
41
41
  global.PointerEvent = class FakePointerEvent extends MouseEvent {
package/src/tree.ts CHANGED
@@ -41,7 +41,7 @@ export class TreeTester {
41
41
  /**
42
42
  * Set the interaction type used by the tree tester.
43
43
  */
44
- setInteractionType(type: UserOpts['interactionType']) {
44
+ setInteractionType(type: UserOpts['interactionType']): void {
45
45
  this._interactionType = type;
46
46
  };
47
47
 
@@ -71,6 +71,11 @@ export class TreeTester {
71
71
  if (targetIndex === -1) {
72
72
  throw new Error('Option provided is not in the tree');
73
73
  }
74
+
75
+ if (document.activeElement !== this._tree || !this._tree.contains(document.activeElement)) {
76
+ act(() => this._tree.focus());
77
+ }
78
+
74
79
  if (document.activeElement === this.tree) {
75
80
  await this.user.keyboard('[ArrowDown]');
76
81
  } else if (this._tree.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') {
@@ -92,7 +97,7 @@ export class TreeTester {
92
97
  /**
93
98
  * Toggles the selection for the specified tree row. Defaults to using the interaction type set on the tree tester.
94
99
  */
95
- async toggleRowSelection(opts: TreeToggleRowOpts) {
100
+ async toggleRowSelection(opts: TreeToggleRowOpts): Promise<void> {
96
101
  let {
97
102
  row,
98
103
  needsLongPress,
@@ -143,7 +148,7 @@ export class TreeTester {
143
148
  /**
144
149
  * Toggles the expansion for the specified tree row. Defaults to using the interaction type set on the tree tester.
145
150
  */
146
- async toggleRowExpansion(opts: TreeToggleExpansionOpts) {
151
+ async toggleRowExpansion(opts: TreeToggleExpansionOpts): Promise<void> {
147
152
  let {
148
153
  row,
149
154
  interactionType = this._interactionType
@@ -184,7 +189,7 @@ export class TreeTester {
184
189
  /**
185
190
  * Triggers the action for the specified tree row. Defaults to using the interaction type set on the tree tester.
186
191
  */
187
- async triggerRowAction(opts: TreeRowActionOpts) {
192
+ async triggerRowAction(opts: TreeRowActionOpts): Promise<void> {
188
193
  let {
189
194
  row,
190
195
  needsDoubleClick,
@@ -206,10 +211,6 @@ export class TreeTester {
206
211
  return;
207
212
  }
208
213
 
209
- if (document.activeElement !== this._tree || !this._tree.contains(document.activeElement)) {
210
- act(() => this._tree.focus());
211
- }
212
-
213
214
  await this.keyboardNavigateToRow({row});
214
215
  await this.user.keyboard('[Enter]');
215
216
  } else {
package/src/types.ts CHANGED
@@ -22,14 +22,14 @@ export interface UserOpts {
22
22
  * @default mouse
23
23
  */
24
24
  interactionType?: 'mouse' | 'touch' | 'keyboard',
25
- // If using fake timers user should provide something like (time) => jest.advanceTimersByTime(time))}
26
- // A real timer user would pass async () => await new Promise((resolve) => setTimeout(resolve, waitTime))
25
+ // If using fake timers user should provide something like (time) => jest.advanceTimersByTime(time))}.
26
+ // A real timer user would pass (waitTime) => new Promise((resolve) => setTimeout(resolve, waitTime))
27
27
  // Time is in ms.
28
28
  /**
29
29
  * A function used by the test utils to advance timers during interactions. Required for certain aria patterns (e.g. table). This can be overridden
30
30
  * at the aria pattern tester level if needed.
31
31
  */
32
- advanceTimer?: (time?: number) => void | Promise<unknown>
32
+ advanceTimer?: (time: number) => unknown | Promise<unknown>
33
33
  }
34
34
 
35
35
  export interface BaseTesterOpts extends UserOpts {
@@ -69,7 +69,11 @@ export interface MenuTesterOpts extends BaseTesterOpts {
69
69
  /**
70
70
  * Whether the current menu is a submenu.
71
71
  */
72
- isSubmenu?: boolean
72
+ isSubmenu?: boolean,
73
+ /**
74
+ * The root menu of the menu tree. Only available if the menu is a submenu.
75
+ */
76
+ rootMenu?: HTMLElement
73
77
  }
74
78
 
75
79
  export interface SelectTesterOpts extends BaseTesterOpts {
package/src/user.ts CHANGED
@@ -67,7 +67,7 @@ type TesterOpts<T> =
67
67
  T extends 'Tree' ? TreeTesterOpts :
68
68
  never;
69
69
 
70
- let defaultAdvanceTimer = async (waitTime: number | undefined) => await new Promise((resolve) => setTimeout(resolve, waitTime));
70
+ let defaultAdvanceTimer = (waitTime: number | undefined) => new Promise((resolve) => setTimeout(resolve, waitTime));
71
71
 
72
72
  export class User {
73
73
  private user;