@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.
- package/LICENSE +201 -0
- package/dist/combobox.main.js +116 -82
- package/dist/combobox.main.js.map +1 -1
- package/dist/combobox.mjs +117 -83
- package/dist/combobox.module.js +117 -83
- package/dist/combobox.module.js.map +1 -1
- package/dist/events.main.js.map +1 -1
- package/dist/events.module.js.map +1 -1
- package/dist/gridlist.main.js +102 -59
- package/dist/gridlist.main.js.map +1 -1
- package/dist/gridlist.mjs +103 -60
- package/dist/gridlist.module.js +103 -60
- package/dist/gridlist.module.js.map +1 -1
- package/dist/listbox.main.js +135 -0
- package/dist/listbox.main.js.map +1 -0
- package/dist/listbox.mjs +130 -0
- package/dist/listbox.module.js +130 -0
- package/dist/listbox.module.js.map +1 -0
- package/dist/main.js.map +1 -1
- package/dist/menu.main.js +197 -119
- package/dist/menu.main.js.map +1 -1
- package/dist/menu.mjs +198 -120
- package/dist/menu.module.js +198 -120
- package/dist/menu.module.js.map +1 -1
- package/dist/module.js.map +1 -1
- package/dist/select.main.js +116 -70
- package/dist/select.main.js.map +1 -1
- package/dist/select.mjs +117 -71
- package/dist/select.module.js +117 -71
- package/dist/select.module.js.map +1 -1
- package/dist/table.main.js +170 -143
- package/dist/table.main.js.map +1 -1
- package/dist/table.mjs +171 -144
- package/dist/table.module.js +171 -144
- package/dist/table.module.js.map +1 -1
- package/dist/tabs.main.js +133 -0
- package/dist/tabs.main.js.map +1 -0
- package/dist/tabs.mjs +128 -0
- package/dist/tabs.module.js +128 -0
- package/dist/tabs.module.js.map +1 -0
- package/dist/testSetup.main.js +2 -6
- package/dist/testSetup.main.js.map +1 -1
- package/dist/testSetup.mjs +2 -6
- package/dist/testSetup.module.js +2 -6
- package/dist/testSetup.module.js.map +1 -1
- package/dist/tree.main.js +165 -0
- package/dist/tree.main.js.map +1 -0
- package/dist/tree.mjs +160 -0
- package/dist/tree.module.js +160 -0
- package/dist/tree.module.js.map +1 -0
- package/dist/types.d.ts +607 -139
- package/dist/types.d.ts.map +1 -1
- package/dist/user.main.js +16 -5
- package/dist/user.main.js.map +1 -1
- package/dist/user.mjs +16 -5
- package/dist/user.module.js +16 -5
- package/dist/user.module.js.map +1 -1
- package/package.json +5 -5
- package/src/combobox.ts +105 -36
- package/src/events.ts +1 -1
- package/src/gridlist.ts +146 -59
- package/src/index.ts +1 -1
- package/src/listbox.ts +226 -0
- package/src/menu.ts +207 -55
- package/src/select.ts +137 -44
- package/src/table.ts +134 -78
- package/src/tabs.ts +198 -0
- package/src/testSetup.ts +2 -6
- package/src/tree.ts +248 -0
- package/src/types.ts +133 -0
- 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 {
|
|
14
|
+
import {MenuTesterOpts, UserOpts} from './types';
|
|
15
15
|
import {triggerLongPress} from './events';
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
115
|
-
option =
|
|
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.
|
|
124
|
-
await this.user.keyboard(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) :
|
|
353
|
+
return menuId ? document.getElementById(menuId) : null;
|
|
211
354
|
}
|
|
212
355
|
|
|
213
|
-
|
|
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
|
-
|
|
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(
|
|
377
|
+
options = within(element).queryAllByRole('menuitemradio');
|
|
220
378
|
if (options.length === 0) {
|
|
221
|
-
options = within(
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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 {
|
|
14
|
+
import {SelectTesterOpts, UserOpts} from './types';
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Returns the select's trigger.
|
|
231
|
+
*/
|
|
232
|
+
get trigger(): HTMLElement {
|
|
141
233
|
return this._trigger;
|
|
142
234
|
}
|
|
143
235
|
|
|
144
|
-
|
|
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) :
|
|
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
|
-
|
|
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
|
}
|