Skip to content

Commit 32a9a54

Browse files
authored
feat: Initial aria test util docs and listbox/tabs/tree utils (#7145)
* scaffolding and documenting tester api * fix strict mode * update utils for conformity and add intro * add instalation, setup, and update method types * update docs with examples and update types * remove some todos * forgot to remove a only * fix lint * fix lint again, for some reason local lint doesnt catch this one... * review comments * update select option methods to accept node, string, and index using a single unified option for simplicity * fix tests and more consistency refactors * updating copy per review * feat: Next batch of aria utils (Listbox, Tabs, Tree) (#7505) * adding listbox test utils and clean up of other utils * check that util works in tests * add docs * test listbox util get options section scoping * tabs test utils * add tests for tab test utils * add docs for tabs testing * update jsdoc and adding version badge * pulling in tree utils from s2-treeview branch modified some of the utils for consistency, but otherwise kept most of it the same. Changes to be discussed * update docs and use the utils in the spectrum tests * making things more consistent * fix tests temporarily * fix keyboard navigation if row is disabled * review comments * small fixes from review * update testing pages to be more standalone as per review * add alpha badge * review comments
1 parent 191df02 commit 32a9a54

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+3351
-840
lines changed

packages/@react-aria/test-utils/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
"peerDependencies": {
2828
"@testing-library/react": "^15.0.7",
2929
"@testing-library/user-event": "^13.0.0 || ^14.0.0",
30-
"jest": "^29.5.0",
3130
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
3231
},
3332
"publishConfig": {

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

Lines changed: 105 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,34 @@
1111
*/
1212

1313
import {act, waitFor, within} from '@testing-library/react';
14-
import {BaseTesterOpts, UserOpts} from './user';
14+
import {ComboBoxTesterOpts, UserOpts} from './types';
1515

16-
export interface ComboBoxOptions extends UserOpts, BaseTesterOpts {
17-
user?: any,
18-
trigger?: HTMLElement
16+
interface ComboBoxOpenOpts {
17+
/**
18+
* Whether the combobox opens on focus or needs to be manually opened via user action.
19+
* @default 'manual'
20+
*/
21+
triggerBehavior?: 'focus' | 'manual',
22+
/**
23+
* What interaction type to use when opening the combobox. Defaults to the interaction type set on the tester.
24+
*/
25+
interactionType?: UserOpts['interactionType']
26+
}
27+
28+
interface ComboBoxSelectOpts extends ComboBoxOpenOpts {
29+
/**
30+
* The index, text, or node of the option to select. Option nodes can be sourced via `options()`.
31+
*/
32+
option: number | string | HTMLElement
1933
}
2034

2135
export class ComboBoxTester {
2236
private user;
2337
private _interactionType: UserOpts['interactionType'];
2438
private _combobox: HTMLElement;
25-
private _trigger: HTMLElement | undefined;
39+
private _trigger: HTMLElement;
2640

27-
constructor(opts: ComboBoxOptions) {
41+
constructor(opts: ComboBoxTesterOpts) {
2842
let {root, trigger, user, interactionType} = opts;
2943
this.user = user;
3044
this._interactionType = interactionType || 'mouse';
@@ -52,11 +66,17 @@ export class ComboBoxTester {
5266
}
5367
}
5468

55-
setInteractionType = (type: UserOpts['interactionType']) => {
69+
/**
70+
* Set the interaction type used by the combobox tester.
71+
*/
72+
setInteractionType(type: UserOpts['interactionType']) {
5673
this._interactionType = type;
57-
};
74+
}
5875

59-
open = async (opts: {triggerBehavior?: 'focus' | 'manual', interactionType?: UserOpts['interactionType']} = {}) => {
76+
/**
77+
* Opens the combobox dropdown. Defaults to using the interaction type set on the combobox tester.
78+
*/
79+
async open(opts: ComboBoxOpenOpts = {}) {
6080
let {triggerBehavior = 'manual', interactionType = this._interactionType} = opts;
6181
let trigger = this.trigger;
6282
let combobox = this.combobox;
@@ -96,18 +116,51 @@ export class ComboBoxTester {
96116
return true;
97117
}
98118
});
99-
};
119+
}
120+
121+
/**
122+
* Returns an option matching the specified index or text content.
123+
*/
124+
findOption(opts: {optionIndexOrText: number | string}): HTMLElement {
125+
let {
126+
optionIndexOrText
127+
} = opts;
128+
129+
let option;
130+
let options = this.options();
131+
let listbox = this.listbox;
100132

101-
selectOption = async (opts: {option?: HTMLElement, optionText?: string, triggerBehavior?: 'focus' | 'manual', interactionType?: UserOpts['interactionType']} = {}) => {
102-
let {optionText, option, triggerBehavior, interactionType = this._interactionType} = opts;
133+
if (typeof optionIndexOrText === 'number') {
134+
option = options[optionIndexOrText];
135+
} else if (typeof optionIndexOrText === 'string' && listbox != null) {
136+
option = (within(listbox!).getByText(optionIndexOrText).closest('[role=option]'))! as HTMLElement;
137+
}
138+
139+
return option;
140+
}
141+
142+
/**
143+
* Selects the desired combobox option. Defaults to using the interaction type set on the combobox tester. If necessary, will open the combobox dropdown beforehand.
144+
* The desired option can be targeted via the option's node, the option's text, or the option's index.
145+
*/
146+
async selectOption(opts: ComboBoxSelectOpts) {
147+
let {option, triggerBehavior, interactionType = this._interactionType} = opts;
103148
if (!this.combobox.getAttribute('aria-controls')) {
104149
await this.open({triggerBehavior});
105150
}
106151

107152
let listbox = this.listbox;
153+
if (!listbox) {
154+
throw new Error('Combobox\'s listbox not found.');
155+
}
156+
108157
if (listbox) {
109-
if (!option && optionText) {
110-
option = within(listbox).getByText(optionText);
158+
if (typeof option === 'string' || typeof option === 'number') {
159+
option = this.findOption({optionIndexOrText: option});
160+
}
161+
162+
if (!option) {
163+
throw new Error('Target option not found in the listbox.');
111164
}
112165

113166
// TODO: keyboard method of selecting the the option is a bit tricky unless I simply simulate the user pressing the down arrow
@@ -118,7 +171,7 @@ export class ComboBoxTester {
118171
await this.user.pointer({target: option, keys: '[TouchA]'});
119172
}
120173

121-
if (option && option.getAttribute('href') == null) {
174+
if (option.getAttribute('href') == null) {
122175
await waitFor(() => {
123176
if (document.contains(listbox)) {
124177
throw new Error('Expected listbox element to not be in the document after selecting an option');
@@ -130,9 +183,12 @@ export class ComboBoxTester {
130183
} else {
131184
throw new Error("Attempted to select a option in the combobox, but the listbox wasn't found.");
132185
}
133-
};
186+
}
134187

135-
close = async () => {
188+
/**
189+
* Closes the combobox dropdown.
190+
*/
191+
async close() {
136192
let listbox = this.listbox;
137193
if (listbox) {
138194
act(() => this.combobox.focus());
@@ -146,43 +202,56 @@ export class ComboBoxTester {
146202
}
147203
});
148204
}
149-
};
205+
}
150206

151-
get combobox() {
207+
/**
208+
* Returns the combobox.
209+
*/
210+
get combobox(): HTMLElement {
152211
return this._combobox;
153212
}
154213

155-
get trigger() {
214+
/**
215+
* Returns the combobox trigger button.
216+
*/
217+
get trigger(): HTMLElement {
156218
return this._trigger;
157219
}
158220

159-
get listbox() {
221+
/**
222+
* Returns the combobox's listbox if present.
223+
*/
224+
get listbox(): HTMLElement | null {
160225
let listBoxId = this.combobox.getAttribute('aria-controls');
161-
return listBoxId ? document.getElementById(listBoxId) || undefined : undefined;
226+
return listBoxId ? document.getElementById(listBoxId) || null : null;
227+
}
228+
229+
/**
230+
* Returns the combobox's sections if present.
231+
*/
232+
get sections(): HTMLElement[] {
233+
let listbox = this.listbox;
234+
return listbox ? within(listbox).queryAllByRole('group') : [];
162235
}
163236

164-
options = (opts: {element?: HTMLElement} = {}): HTMLElement[] | never[] => {
165-
let {element} = opts;
166-
element = element || this.listbox;
237+
/**
238+
* Returns the combobox's options if present. Can be filtered to a subsection of the listbox if provided via `element`.
239+
*/
240+
options(opts: {element?: HTMLElement} = {}): HTMLElement[] {
241+
let {element = this.listbox} = opts;
167242
let options = [];
168243
if (element) {
169244
options = within(element).queryAllByRole('option');
170245
}
171246

172247
return options;
173-
};
174-
175-
get sections() {
176-
let listbox = this.listbox;
177-
if (listbox) {
178-
return within(listbox).queryAllByRole('group');
179-
} else {
180-
return [];
181-
}
182248
}
183249

184-
get focusedOption() {
250+
/**
251+
* Returns the currently focused option in the combobox's dropdown if any.
252+
*/
253+
get focusedOption(): HTMLElement | null {
185254
let focusedOptionId = this.combobox.getAttribute('aria-activedescendant');
186-
return focusedOptionId ? document.getElementById(focusedOptionId) : undefined;
255+
return focusedOptionId ? document.getElementById(focusedOptionId) : null;
187256
}
188257
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {act, fireEvent} from '@testing-library/react';
14-
import {UserOpts} from './user';
14+
import {UserOpts} from './types';
1515

1616
export const DEFAULT_LONG_PRESS_TIME = 500;
1717

0 commit comments

Comments
 (0)