@react-aria/test-utils 1.0.0-alpha.2 → 1.0.0-alpha.4

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 (71) hide show
  1. package/LICENSE +201 -0
  2. package/dist/combobox.main.js +116 -82
  3. package/dist/combobox.main.js.map +1 -1
  4. package/dist/combobox.mjs +117 -83
  5. package/dist/combobox.module.js +117 -83
  6. package/dist/combobox.module.js.map +1 -1
  7. package/dist/events.main.js.map +1 -1
  8. package/dist/events.module.js.map +1 -1
  9. package/dist/gridlist.main.js +102 -59
  10. package/dist/gridlist.main.js.map +1 -1
  11. package/dist/gridlist.mjs +103 -60
  12. package/dist/gridlist.module.js +103 -60
  13. package/dist/gridlist.module.js.map +1 -1
  14. package/dist/listbox.main.js +135 -0
  15. package/dist/listbox.main.js.map +1 -0
  16. package/dist/listbox.mjs +130 -0
  17. package/dist/listbox.module.js +130 -0
  18. package/dist/listbox.module.js.map +1 -0
  19. package/dist/main.js.map +1 -1
  20. package/dist/menu.main.js +197 -119
  21. package/dist/menu.main.js.map +1 -1
  22. package/dist/menu.mjs +198 -120
  23. package/dist/menu.module.js +198 -120
  24. package/dist/menu.module.js.map +1 -1
  25. package/dist/module.js.map +1 -1
  26. package/dist/select.main.js +116 -70
  27. package/dist/select.main.js.map +1 -1
  28. package/dist/select.mjs +117 -71
  29. package/dist/select.module.js +117 -71
  30. package/dist/select.module.js.map +1 -1
  31. package/dist/table.main.js +170 -143
  32. package/dist/table.main.js.map +1 -1
  33. package/dist/table.mjs +171 -144
  34. package/dist/table.module.js +171 -144
  35. package/dist/table.module.js.map +1 -1
  36. package/dist/tabs.main.js +133 -0
  37. package/dist/tabs.main.js.map +1 -0
  38. package/dist/tabs.mjs +128 -0
  39. package/dist/tabs.module.js +128 -0
  40. package/dist/tabs.module.js.map +1 -0
  41. package/dist/testSetup.main.js +2 -6
  42. package/dist/testSetup.main.js.map +1 -1
  43. package/dist/testSetup.mjs +2 -6
  44. package/dist/testSetup.module.js +2 -6
  45. package/dist/testSetup.module.js.map +1 -1
  46. package/dist/tree.main.js +165 -0
  47. package/dist/tree.main.js.map +1 -0
  48. package/dist/tree.mjs +160 -0
  49. package/dist/tree.module.js +160 -0
  50. package/dist/tree.module.js.map +1 -0
  51. package/dist/types.d.ts +607 -139
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/user.main.js +16 -5
  54. package/dist/user.main.js.map +1 -1
  55. package/dist/user.mjs +16 -5
  56. package/dist/user.module.js +16 -5
  57. package/dist/user.module.js.map +1 -1
  58. package/package.json +5 -5
  59. package/src/combobox.ts +105 -36
  60. package/src/events.ts +1 -1
  61. package/src/gridlist.ts +146 -59
  62. package/src/index.ts +1 -1
  63. package/src/listbox.ts +226 -0
  64. package/src/menu.ts +207 -55
  65. package/src/select.ts +137 -44
  66. package/src/table.ts +134 -78
  67. package/src/tabs.ts +198 -0
  68. package/src/testSetup.ts +2 -6
  69. package/src/tree.ts +248 -0
  70. package/src/types.ts +133 -0
  71. package/src/user.ts +62 -37
package/src/menu.ts CHANGED
@@ -11,20 +11,62 @@
11
11
  */
12
12
 
13
13
  import {act, waitFor, within} from '@testing-library/react';
14
- import {BaseTesterOpts, UserOpts} from './user';
14
+ import {MenuTesterOpts, UserOpts} from './types';
15
15
  import {triggerLongPress} from './events';
16
16
 
17
- export interface MenuOptions extends UserOpts, BaseTesterOpts {
18
- user: any
17
+ interface MenuOpenOpts {
18
+ /**
19
+ * Whether the menu needs to be long pressed to open.
20
+ */
21
+ needsLongPress?: boolean,
22
+ /**
23
+ * What interaction type to use when opening the menu. Defaults to the interaction type set on the tester.
24
+ */
25
+ interactionType?: UserOpts['interactionType'],
26
+ /**
27
+ * Whether to open the menu via ArrowUp or ArrowDown if in keyboard modality.
28
+ */
29
+ direction?: 'up' | 'down'
19
30
  }
31
+
32
+ interface MenuSelectOpts extends MenuOpenOpts {
33
+ /**
34
+ * The index, text, or node of the option to select. Option nodes can be sourced via `options()`.
35
+ */
36
+ option: number | string | HTMLElement,
37
+ /**
38
+ * The menu's selection mode. Will affect whether or not the menu is expected to be closed upon option selection.
39
+ * @default 'single'
40
+ */
41
+ menuSelectionMode?: 'single' | 'multiple',
42
+ /**
43
+ * Whether or not the menu closes on select. Depends on menu implementation and configuration.
44
+ * @default true
45
+ */
46
+ closesOnSelect?: boolean,
47
+ /**
48
+ * Whether the option should be triggered by Space or Enter in keyboard modality.
49
+ * @default 'Enter'
50
+ */
51
+ keyboardActivation?: 'Space' | 'Enter'
52
+ }
53
+
54
+ interface MenuOpenSubmenuOpts extends MenuOpenOpts {
55
+ /**
56
+ * The text or node of the submenu trigger to open. Available submenu trigger nodes can be sourced via `submenuTriggers`.
57
+ */
58
+ submenuTrigger: string | HTMLElement
59
+ }
60
+
20
61
  export class MenuTester {
21
62
  private user;
22
63
  private _interactionType: UserOpts['interactionType'];
23
64
  private _advanceTimer: UserOpts['advanceTimer'];
24
- private _trigger: HTMLElement;
65
+ private _trigger: HTMLElement | undefined;
66
+ private _isSubmenu: boolean = false;
25
67
 
26
- constructor(opts: MenuOptions) {
27
- let {root, user, interactionType, advanceTimer} = opts;
68
+ constructor(opts: MenuTesterOpts) {
69
+ let {root, user, interactionType, advanceTimer, isSubmenu} = opts;
28
70
  this.user = user;
29
71
  this._interactionType = interactionType || 'mouse';
30
72
  this._advanceTimer = advanceTimer;
@@ -41,20 +83,28 @@ export class MenuTester {
41
83
  this._trigger = root;
42
84
  }
43
85
  }
86
+
87
+ this._isSubmenu = isSubmenu || false;
44
88
  }
45
89
 
46
- setInteractionType = (type: UserOpts['interactionType']) => {
90
+ /**
91
+ * Set the interaction type used by the menu tester.
92
+ */
93
+ setInteractionType(type: UserOpts['interactionType']) {
47
94
  this._interactionType = type;
48
- };
95
+ }
49
96
 
50
97
  // TODO: this has been common to select as well, maybe make select use it? Or make a generic method. Will need to make error messages generic
51
98
  // One difference will be that it supports long press as well
52
- open = async (opts: {needsLongPress?: boolean, interactionType?: UserOpts['interactionType']} = {}) => {
99
+ /**
100
+ * Opens the menu. Defaults to using the interaction type set on the menu tester.
101
+ */
102
+ async open(opts: MenuOpenOpts = {}) {
53
103
  let {
54
104
  needsLongPress,
55
- interactionType = this._interactionType
105
+ interactionType = this._interactionType,
106
+ direction
56
107
  } = opts;
57
-
58
108
  let trigger = this.trigger;
59
109
  let isDisabled = trigger.hasAttribute('disabled');
60
110
  if (interactionType === 'mouse' || interactionType === 'touch') {
@@ -70,8 +120,16 @@ export class MenuTester {
70
120
  await this.user.pointer({target: trigger, keys: '[TouchA]'});
71
121
  }
72
122
  } else if (interactionType === 'keyboard' && !isDisabled) {
73
- act(() => trigger.focus());
74
- await this.user.keyboard('[Enter]');
123
+ if (direction === 'up') {
124
+ act(() => trigger.focus());
125
+ await this.user.keyboard('[ArrowUp]');
126
+ } else if (direction === 'down') {
127
+ act(() => trigger.focus());
128
+ await this.user.keyboard('[ArrowDown]');
129
+ } else {
130
+ act(() => trigger.focus());
131
+ await this.user.keyboard('[Enter]');
132
+ }
75
133
  }
76
134
 
77
135
  await waitFor(() => {
@@ -91,37 +149,76 @@ export class MenuTester {
91
149
  }
92
150
  });
93
151
  }
94
- };
152
+ }
153
+
154
+ /**
155
+ * Returns a option matching the specified index or text content.
156
+ */
157
+ findOption(opts: {optionIndexOrText: number | string}): HTMLElement {
158
+ let {
159
+ optionIndexOrText
160
+ } = opts;
161
+
162
+ let option;
163
+ let options = this.options();
164
+ let menu = this.menu;
165
+
166
+ if (typeof optionIndexOrText === 'number') {
167
+ option = options[optionIndexOrText];
168
+ } else if (typeof optionIndexOrText === 'string' && menu != null) {
169
+ option = (within(menu!).getByText(optionIndexOrText).closest('[role=menuitem], [role=menuitemradio], [role=menuitemcheckbox]'))! as HTMLElement;
170
+ }
171
+
172
+ return option;
173
+ }
95
174
 
96
175
  // TODO: also very similar to select, barring potential long press support
97
176
  // Close on select is also kinda specific?
98
- selectOption = async (opts: {option?: HTMLElement, optionText?: string, menuSelectionMode?: 'single' | 'multiple', needsLongPress?: boolean, closesOnSelect?: boolean, interactionType?: UserOpts['interactionType']}) => {
177
+ /**
178
+ * 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
+ * The desired option can be targeted via the option's node, the option's text, or the option's index.
180
+ */
181
+ async selectOption(opts: MenuSelectOpts) {
99
182
  let {
100
- optionText,
101
183
  menuSelectionMode = 'single',
102
184
  needsLongPress,
103
185
  closesOnSelect = true,
104
186
  option,
105
- interactionType = this._interactionType
187
+ interactionType = this._interactionType,
188
+ keyboardActivation = 'Enter'
106
189
  } = opts;
107
190
  let trigger = this.trigger;
108
- if (!trigger.getAttribute('aria-controls')) {
191
+
192
+ if (!trigger.getAttribute('aria-controls') && !trigger.hasAttribute('aria-expanded')) {
109
193
  await this.open({needsLongPress});
110
194
  }
111
195
 
112
196
  let menu = this.menu;
197
+
198
+ if (!menu) {
199
+ throw new Error('Menu not found.');
200
+ }
201
+
113
202
  if (menu) {
114
- if (!option && optionText) {
115
- option = within(menu).getByText(optionText);
203
+ if (typeof option === 'string' || typeof option === 'number') {
204
+ option = this.findOption({optionIndexOrText: option});
205
+ }
206
+
207
+ if (!option) {
208
+ throw new Error('Target option not found in the menu.');
116
209
  }
117
210
 
118
211
  if (interactionType === 'keyboard') {
212
+ if (option?.getAttribute('aria-disabled') === 'true') {
213
+ return;
214
+ }
215
+
119
216
  if (document.activeElement !== menu || !menu.contains(document.activeElement)) {
120
217
  act(() => menu.focus());
121
218
  }
122
219
 
123
- await this.user.keyboard(optionText);
124
- await this.user.keyboard('[Enter]');
220
+ await this.keyboardNavigateToOption({option});
221
+ await this.user.keyboard(`[${keyboardActivation}]`);
125
222
  } else {
126
223
  if (interactionType === 'mouse') {
127
224
  await this.user.click(option);
@@ -129,8 +226,9 @@ export class MenuTester {
129
226
  await this.user.pointer({target: option, keys: '[TouchA]'});
130
227
  }
131
228
  }
229
+ act(() => {jest.runAllTimers();});
132
230
 
133
- if (option && option.getAttribute('href') == null && option.getAttribute('aria-haspopup') == null && menuSelectionMode === 'single' && closesOnSelect) {
231
+ if (option.getAttribute('href') == null && option.getAttribute('aria-haspopup') == null && menuSelectionMode === 'single' && closesOnSelect && keyboardActivation !== 'Space' && !this._isSubmenu) {
134
232
  await waitFor(() => {
135
233
  if (document.activeElement !== trigger) {
136
234
  throw new Error(`Expected the document.activeElement after selecting an option to be the menu trigger but got ${document.activeElement}`);
@@ -146,16 +244,19 @@ export class MenuTester {
146
244
  } else {
147
245
  throw new Error("Attempted to select a option in the menu, but menu wasn't found.");
148
246
  }
149
- };
247
+ }
150
248
 
151
249
  // TODO: update this to remove needsLongPress if we wanna make the user call open first always
152
- openSubmenu = async (opts: {submenuTrigger?: HTMLElement, submenuTriggerText?: string, needsLongPress?: boolean, interactionType?: UserOpts['interactionType']}): Promise<MenuTester | null> => {
250
+ /**
251
+ * Opens the submenu. Defaults to using the interaction type set on the menu tester. The submenu trigger can be targeted via the trigger's node or the trigger's text.
252
+ */
253
+ async openSubmenu(opts: MenuOpenSubmenuOpts): Promise<MenuTester | null> {
153
254
  let {
154
255
  submenuTrigger,
155
- submenuTriggerText,
156
256
  needsLongPress,
157
257
  interactionType = this._interactionType
158
258
  } = opts;
259
+
159
260
  let trigger = this.trigger;
160
261
  let isDisabled = trigger.hasAttribute('disabled');
161
262
  if (!trigger.getAttribute('aria-controls') && !isDisabled) {
@@ -164,24 +265,56 @@ export class MenuTester {
164
265
  if (!isDisabled) {
165
266
  let menu = this.menu;
166
267
  if (menu) {
167
- let submenu;
168
- if (submenuTrigger) {
169
- submenu = submenuTrigger;
170
- } else if (submenuTriggerText) {
171
- submenu = within(menu).getByText(submenuTriggerText);
268
+ if (typeof submenuTrigger === 'string') {
269
+ submenuTrigger = (within(menu!).getByText(submenuTrigger).closest('[role=menuitem]'))! as HTMLElement;
270
+ }
271
+
272
+ let submenuTriggerTester = new MenuTester({user: this.user, interactionType: this._interactionType, root: submenuTrigger, isSubmenu: true});
273
+ if (interactionType === 'mouse') {
274
+ await this.user.pointer({target: submenuTrigger});
275
+ act(() => {jest.runAllTimers();});
276
+ } else if (interactionType === 'keyboard') {
277
+ await this.keyboardNavigateToOption({option: submenuTrigger});
278
+ await this.user.keyboard('[ArrowRight]');
279
+ act(() => {jest.runAllTimers();});
280
+ } else {
281
+ await submenuTriggerTester.open();
172
282
  }
173
283
 
174
- let submenuTriggerTester = new MenuTester({user: this.user, interactionType: interactionType, root: submenu});
175
- await submenuTriggerTester.open();
176
284
 
177
285
  return submenuTriggerTester;
178
286
  }
179
287
  }
180
288
 
181
289
  return null;
290
+ }
291
+
292
+ private async keyboardNavigateToOption(opts: {option: HTMLElement}) {
293
+ let {option} = opts;
294
+ let options = this.options();
295
+ let targetIndex = options.findIndex(opt => (opt === option) || opt.contains(option));
296
+
297
+ if (targetIndex === -1) {
298
+ throw new Error('Option provided is not in the menu');
299
+ }
300
+ if (document.activeElement === this.menu) {
301
+ await this.user.keyboard('[ArrowDown]');
302
+ }
303
+ let currIndex = options.indexOf(document.activeElement as HTMLElement);
304
+ if (currIndex === -1) {
305
+ throw new Error('ActiveElement is not in the menu');
306
+ }
307
+ let direction = targetIndex > currIndex ? 'down' : 'up';
308
+
309
+ for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
310
+ await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
311
+ }
182
312
  };
183
313
 
184
- close = async () => {
314
+ /**
315
+ * Closes the menu.
316
+ */
317
+ async close() {
185
318
  let menu = this.menu;
186
319
  if (menu) {
187
320
  act(() => menu.focus());
@@ -199,26 +332,51 @@ export class MenuTester {
199
332
  throw new Error('Expected the menu to not be in the document after closing it.');
200
333
  }
201
334
  }
202
- };
335
+ }
336
+
337
+ /**
338
+ * Returns the menu's trigger.
339
+ */
340
+ get trigger(): HTMLElement {
341
+ if (!this._trigger) {
342
+ throw new Error('No trigger element found for menu.');
343
+ }
203
344
 
204
- get trigger() {
205
345
  return this._trigger;
206
346
  }
207
347
 
208
- get menu() {
348
+ /**
349
+ * Returns the menu if present.
350
+ */
351
+ get menu(): HTMLElement | null {
209
352
  let menuId = this.trigger.getAttribute('aria-controls');
210
- return menuId ? document.getElementById(menuId) : undefined;
353
+ return menuId ? document.getElementById(menuId) : null;
211
354
  }
212
355
 
213
- get options(): HTMLElement[] | never[] {
356
+ /**
357
+ * Returns the menu's sections if any.
358
+ */
359
+ get sections(): HTMLElement[] {
214
360
  let menu = this.menu;
215
- let options = [];
216
361
  if (menu) {
217
- options = within(menu).queryAllByRole('menuitem');
362
+ return within(menu).queryAllByRole('group');
363
+ } else {
364
+ return [];
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Returns the menu's options if present. Can be filtered to a subsection of the menu if provided via `element`.
370
+ */
371
+ options(opts: {element?: HTMLElement} = {}): HTMLElement[] {
372
+ let {element = this.menu} = opts;
373
+ let options: HTMLElement[] = [];
374
+ if (element) {
375
+ options = within(element).queryAllByRole('menuitem');
218
376
  if (options.length === 0) {
219
- options = within(menu).queryAllByRole('menuitemradio');
377
+ options = within(element).queryAllByRole('menuitemradio');
220
378
  if (options.length === 0) {
221
- options = within(menu).queryAllByRole('menuitemcheckbox');
379
+ options = within(element).queryAllByRole('menuitemcheckbox');
222
380
  }
223
381
  }
224
382
  }
@@ -226,19 +384,13 @@ export class MenuTester {
226
384
  return options;
227
385
  }
228
386
 
229
- get sections() {
230
- let menu = this.menu;
231
- if (menu) {
232
- return within(menu).queryAllByRole('group');
233
- } else {
234
- return [];
235
- }
236
- }
237
-
238
- get submenuTriggers() {
239
- let options = this.options;
387
+ /**
388
+ * Returns the menu's submenu triggers if any.
389
+ */
390
+ get submenuTriggers(): HTMLElement[] {
391
+ let options = this.options();
240
392
  if (options.length > 0) {
241
- return this.options.filter(item => item.getAttribute('aria-haspopup') != null);
393
+ return options.filter(item => item.getAttribute('aria-haspopup') != null);
242
394
  }
243
395
 
244
396
  return [];
package/src/select.ts CHANGED
@@ -11,18 +11,28 @@
11
11
  */
12
12
 
13
13
  import {act, waitFor, within} from '@testing-library/react';
14
- import {BaseTesterOpts, UserOpts} from './user';
14
+ import {SelectTesterOpts, UserOpts} from './types';
15
15
 
16
- export interface SelectOptions extends UserOpts, BaseTesterOpts {
17
- // TODO: I think the type grabbed from the testing library dist for UserEvent is breaking the build, will need to figure out a better place to grab from
18
- user: any
16
+ interface SelectOpenOpts {
17
+ /**
18
+ * What interaction type to use when opening the select. Defaults to the interaction type set on the tester.
19
+ */
20
+ interactionType?: UserOpts['interactionType']
19
21
  }
22
+
23
+ interface SelectTriggerOptionOpts extends SelectOpenOpts {
24
+ /**
25
+ * The index, text, or node of the option to select. Option nodes can be sourced via `options()`.
26
+ */
27
+ option: number | string | HTMLElement
28
+ }
29
+
20
30
  export class SelectTester {
21
31
  private user;
22
32
  private _interactionType: UserOpts['interactionType'];
23
33
  private _trigger: HTMLElement;
24
34
 
25
- constructor(opts: SelectOptions) {
35
+ constructor(opts: SelectTesterOpts) {
26
36
  let {root, user, interactionType} = opts;
27
37
  this.user = user;
28
38
  this._interactionType = interactionType || 'mouse';
@@ -33,12 +43,17 @@ export class SelectTester {
33
43
  }
34
44
  this._trigger = triggerButton;
35
45
  }
36
-
37
- setInteractionType = (type: UserOpts['interactionType']) => {
46
+ /**
47
+ * Set the interaction type used by the select tester.
48
+ */
49
+ setInteractionType(type: UserOpts['interactionType']) {
38
50
  this._interactionType = type;
39
- };
51
+ }
40
52
 
41
- open = async (opts: {interactionType?: UserOpts['interactionType']} = {}) => {
53
+ /**
54
+ * Opens the select. Defaults to using the interaction type set on the select tester.
55
+ */
56
+ async open(opts: SelectOpenOpts = {}) {
42
57
  let {
43
58
  interactionType = this._interactionType
44
59
  } = opts;
@@ -69,11 +84,80 @@ export class SelectTester {
69
84
  return true;
70
85
  }
71
86
  });
87
+ }
88
+
89
+ /**
90
+ * Closes the select.
91
+ */
92
+ async close() {
93
+ let listbox = this.listbox;
94
+ if (listbox) {
95
+ act(() => listbox.focus());
96
+ await this.user.keyboard('[Escape]');
97
+ }
98
+
99
+ await waitFor(() => {
100
+ if (document.activeElement !== this._trigger) {
101
+ throw new Error(`Expected the document.activeElement after closing the select dropdown to be the select component trigger but got ${document.activeElement}`);
102
+ } else {
103
+ return true;
104
+ }
105
+ });
106
+
107
+ if (listbox && document.contains(listbox)) {
108
+ throw new Error('Expected the select element listbox to not be in the document after closing the dropdown.');
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Returns a option matching the specified index or text content.
114
+ */
115
+ findOption(opts: {optionIndexOrText: number | string}): HTMLElement {
116
+ let {
117
+ optionIndexOrText
118
+ } = opts;
119
+
120
+ let option;
121
+ let options = this.options();
122
+ let listbox = this.listbox;
123
+
124
+ if (typeof optionIndexOrText === 'number') {
125
+ option = options[optionIndexOrText];
126
+ } else if (typeof optionIndexOrText === 'string' && listbox != null) {
127
+ option = (within(listbox!).getByText(optionIndexOrText).closest('[role=option]'))! as HTMLElement;
128
+ }
129
+
130
+ return option;
131
+ }
132
+
133
+ private async keyboardNavigateToOption(opts: {option: HTMLElement}) {
134
+ let {option} = opts;
135
+ let options = this.options();
136
+ let targetIndex = options.indexOf(option);
137
+ if (targetIndex === -1) {
138
+ throw new Error('Option provided is not in the listbox');
139
+ }
140
+ if (document.activeElement === this.listbox) {
141
+ await this.user.keyboard('[ArrowDown]');
142
+ }
143
+ let currIndex = options.indexOf(document.activeElement as HTMLElement);
144
+ if (currIndex === -1) {
145
+ throw new Error('ActiveElement is not in the listbox');
146
+ }
147
+ let direction = targetIndex > currIndex ? 'down' : 'up';
148
+
149
+ for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
150
+ await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
151
+ }
72
152
  };
73
153
 
74
- selectOption = async (opts: {optionText: string, interactionType?: UserOpts['interactionType']}) => {
154
+ /**
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
+ * The desired option can be targeted via the option's node, the option's text, or the option's index.
157
+ */
158
+ async selectOption(opts: SelectTriggerOptionOpts) {
75
159
  let {
76
- optionText,
160
+ option,
77
161
  interactionType = this._interactionType
78
162
  } = opts || {};
79
163
  let trigger = this.trigger;
@@ -81,16 +165,28 @@ export class SelectTester {
81
165
  await this.open();
82
166
  }
83
167
  let listbox = this.listbox;
168
+ if (!listbox) {
169
+ throw new Error('Select\'s listbox not found.');
170
+ }
171
+
84
172
  if (listbox) {
85
- let option = within(listbox).getByText(optionText);
173
+ if (typeof option === 'string' || typeof option === 'number') {
174
+ option = this.findOption({optionIndexOrText: option});
175
+ }
176
+
177
+ if (!option) {
178
+ throw new Error('Target option not found in the listbox.');
179
+ }
180
+
86
181
  if (interactionType === 'keyboard') {
182
+ if (option?.getAttribute('aria-disabled') === 'true') {
183
+ return;
184
+ }
185
+
87
186
  if (document.activeElement !== listbox || !listbox.contains(document.activeElement)) {
88
187
  act(() => listbox.focus());
89
188
  }
90
-
91
- // TODO: this simulates typeahead, do we want to add a helper util for that? Not sure if users would really need that for
92
- // their test
93
- await this.user.keyboard(optionText);
189
+ await this.keyboardNavigateToOption({option});
94
190
  await this.user.keyboard('[Enter]');
95
191
  } else {
96
192
  // TODO: what if the user needs to scroll the list to find the option? What if there are multiple matches for text (hopefully the picker options are pretty unique)
@@ -101,7 +197,7 @@ export class SelectTester {
101
197
  }
102
198
  }
103
199
 
104
- if (option.getAttribute('href') == null) {
200
+ if (option?.getAttribute('href') == null) {
105
201
  await waitFor(() => {
106
202
  if (document.activeElement !== this._trigger) {
107
203
  throw new Error(`Expected the document.activeElement after selecting an option to be the select component trigger but got ${document.activeElement}`);
@@ -115,43 +211,40 @@ export class SelectTester {
115
211
  }
116
212
  }
117
213
  }
118
- };
214
+ }
119
215
 
120
- close = async () => {
121
- let listbox = this.listbox;
122
- if (listbox) {
123
- act(() => listbox.focus());
124
- await this.user.keyboard('[Escape]');
216
+ /**
217
+ * Returns the select's options if present. Can be filtered to a subsection of the listbox if provided via `element`.
218
+ */
219
+ options(opts: {element?: HTMLElement} = {}): HTMLElement[] {
220
+ let {element = this.listbox} = opts;
221
+ let options = [];
222
+ if (element) {
223
+ options = within(element).queryAllByRole('option');
125
224
  }
126
225
 
127
- await waitFor(() => {
128
- if (document.activeElement !== this._trigger) {
129
- throw new Error(`Expected the document.activeElement after closing the select dropdown to be the select component trigger but got ${document.activeElement}`);
130
- } else {
131
- return true;
132
- }
133
- });
134
-
135
- if (listbox && document.contains(listbox)) {
136
- throw new Error('Expected the select element listbox to not be in the document after closing the dropdown.');
137
- }
138
- };
226
+ return options;
227
+ }
139
228
 
140
- get trigger() {
229
+ /**
230
+ * Returns the select's trigger.
231
+ */
232
+ get trigger(): HTMLElement {
141
233
  return this._trigger;
142
234
  }
143
235
 
144
- get listbox() {
236
+ /**
237
+ * Returns the select's listbox if present.
238
+ */
239
+ get listbox(): HTMLElement | null {
145
240
  let listBoxId = this.trigger.getAttribute('aria-controls');
146
- return listBoxId ? document.getElementById(listBoxId) : undefined;
147
- }
148
-
149
- get options() {
150
- let listbox = this.listbox;
151
- return listbox ? within(listbox).queryAllByRole('option') : [];
241
+ return listBoxId ? document.getElementById(listBoxId) : null;
152
242
  }
153
243
 
154
- get sections() {
244
+ /**
245
+ * Returns the select's sections if present.
246
+ */
247
+ get sections(): HTMLElement[] {
155
248
  let listbox = this.listbox;
156
249
  return listbox ? within(listbox).queryAllByRole('group') : [];
157
250
  }