Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial aria test util docs #7145

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/@react-aria/test-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"peerDependencies": {
"@testing-library/react": "^15.0.7",
"@testing-library/user-event": "^13.0.0 || ^14.0.0",
"jest": "^29.5.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? won't this cause some warnings during yarn install?

"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
},
"publishConfig": {
Expand Down
108 changes: 76 additions & 32 deletions packages/@react-aria/test-utils/src/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,38 @@
*/

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

interface ComboBoxOpenOpts {
/**
* Whether the combobox opens on focus or needs to be manually opened via user action.
* @default 'manual'
*/
triggerBehavior?: 'focus' | 'manual',
/**
* What interaction type to use when opening the combobox. Defaults to the interaction type set on the tester.
*/
interactionType?: UserOpts['interactionType']
}

export interface ComboBoxOptions extends UserOpts, BaseTesterOpts {
user: any,
trigger?: HTMLElement
interface ComboBoxSelectOpts extends ComboBoxOpenOpts {
/**
* The option node to select. Option nodes can be sourced via `options()`.
*/
option?: HTMLElement,
/**
* The text of the node to look for when selecting a option. Alternative to `option`.
*/
optionText?: string
}

export class ComboBoxTester {
private user;
private _interactionType: UserOpts['interactionType'];
private _combobox: HTMLElement;
private _trigger: HTMLElement | undefined;
private _trigger: HTMLElement;

constructor(opts: ComboBoxOptions) {
constructor(opts: ComboBoxTesterOpts) {
let {root, trigger, user, interactionType} = opts;
this.user = user;
this._interactionType = interactionType || 'mouse';
Expand Down Expand Up @@ -52,11 +70,17 @@ export class ComboBoxTester {
}
}

setInteractionType = (type: UserOpts['interactionType']) => {
/**
* Set the interaction type used by the combobox tester.
*/
setInteractionType(type: UserOpts['interactionType']) {
this._interactionType = type;
};
}

open = async (opts: {triggerBehavior?: 'focus' | 'manual', interactionType?: UserOpts['interactionType']} = {}) => {
/**
* Opens the combobox dropdown. Defaults to using the interaction type set on the combobox tester.
*/
async open(opts: ComboBoxOpenOpts = {}) {
let {triggerBehavior = 'manual', interactionType = this._interactionType} = opts;
let trigger = this.trigger;
let combobox = this.combobox;
Expand Down Expand Up @@ -96,9 +120,13 @@ export class ComboBoxTester {
return true;
}
});
};
}

selectOption = async (opts: {option?: HTMLElement, optionText?: string, triggerBehavior?: 'focus' | 'manual', interactionType?: UserOpts['interactionType']} = {}) => {
/**
* Selects the desired combobox option. Defaults to using the interaction type set on the combobox tester. If necessary, will open the combobox dropdown beforehand.
* The desired option can be targeted via the option's node or the option's text.
*/
async selectOption(opts: ComboBoxSelectOpts = {}) {
let {optionText, option, triggerBehavior, interactionType = this._interactionType} = opts;
if (!this.combobox.getAttribute('aria-controls')) {
await this.open({triggerBehavior});
Expand Down Expand Up @@ -130,9 +158,12 @@ export class ComboBoxTester {
} else {
throw new Error("Attempted to select a option in the combobox, but the listbox wasn't found.");
}
};
}

close = async () => {
/**
* Closes the combobox dropdown.
*/
async close() {
let listbox = this.listbox;
if (listbox) {
act(() => this.combobox.focus());
Expand All @@ -146,43 +177,56 @@ export class ComboBoxTester {
}
});
}
};
}

get combobox() {
/**
* Returns the combobox.
*/
get combobox(): HTMLElement {
return this._combobox;
}

get trigger() {
/**
* Returns the combobox trigger button if present.
*/
get trigger(): HTMLElement {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if present would imply that this should be. So we should either update to that or change the sentence

get trigger(): HTMLElement | null

return this._trigger;
}

get listbox() {
/**
* Returns the combobox's listbox if present.
*/
get listbox(): HTMLElement | null {
let listBoxId = this.combobox.getAttribute('aria-controls');
return listBoxId ? document.getElementById(listBoxId) || undefined : undefined;
return listBoxId ? document.getElementById(listBoxId) || null : null;
}

options = (opts: {element?: HTMLElement} = {}): HTMLElement[] | never[] => {
let {element} = opts;
element = element || this.listbox;
/**
* Returns the combobox's sections if present.
*/
get sections(): HTMLElement[] {
let listbox = this.listbox;
return listbox ? within(listbox).queryAllByRole('group') : [];
}

/**
* Returns the combobox's options if present. Can be filtered to a subsection of the listbox if provided via `element`.
*/
options(opts: {element?: HTMLElement} = {}): HTMLElement[] {
let {element = this.listbox} = opts;
let options = [];
if (element) {
options = within(element).queryAllByRole('option');
}

return options;
};

get sections() {
let listbox = this.listbox;
if (listbox) {
return within(listbox).queryAllByRole('group');
} else {
return [];
}
}

get focusedOption() {
/**
* Returns the currently focused option in the combobox's dropdown if any.
*/
get focusedOption(): HTMLElement | null {
let focusedOptionId = this.combobox.getAttribute('aria-activedescendant');
return focusedOptionId ? document.getElementById(focusedOptionId) : undefined;
return focusedOptionId ? document.getElementById(focusedOptionId) : null;
}
}
94 changes: 75 additions & 19 deletions packages/@react-aria/test-utils/src/gridlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,73 @@
*/

import {act, within} from '@testing-library/react';
import {BaseTesterOpts, UserOpts} from './user';
import {GridListTesterOpts, UserOpts} from './types';
import {pressElement} from './events';

export interface GridListOptions extends UserOpts, BaseTesterOpts {
user: any
// TODO: this is a bit inconsistent from combobox, perhaps should also take node or combobox should also have find row
interface GridListToggleRowOpts {
/**
* The index of the row to toggle selection for.
*/
index?: number,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if an index isn't supplied?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it's either text OR index, maybe overloads would be more suitable?

/**
* The text of the row to toggle selection for. Alternative to `index`.
*/
text?: string,
/**
* What interaction type to use when toggling the row selection. Defaults to the interaction type set on the tester.
*/
interactionType?: UserOpts['interactionType']
}

interface GridListRowActionOpts {
/**
* The index of the row to trigger its action for.
*/
index?: number,
/**
* The text of the row to trigger its action for. Alternative to `index`.
*/
text?: string,
/**
* What interaction type to use when triggering the row's action. Defaults to the interaction type set on the tester.
*/
interactionType?: UserOpts['interactionType'],
/**
* Whether or not the grid list needs a double click to trigger the row action. Depends on the grid list's implementation.
*/
needsDoubleClick?: boolean
}

export class GridListTester {
private user;
private _interactionType: UserOpts['interactionType'];
private _gridlist: HTMLElement;


constructor(opts: GridListOptions) {
constructor(opts: GridListTesterOpts) {
let {root, user, interactionType} = opts;
this.user = user;
this._interactionType = interactionType || 'mouse';
this._gridlist = root;
}

setInteractionType = (type: UserOpts['interactionType']) => {
/**
* Set the interaction type used by the gridlist tester.
*/
setInteractionType(type: UserOpts['interactionType']) {
this._interactionType = type;
};
}

// TODO: support long press? This is also pretty much the same as table's toggleRowSelection so maybe can share
// For now, don't include long press, see if people need it or if we should just expose long press as a separate util if it isn't very common
// If the current way of passing in the user specified advance timers is ok, then I'd be find including long press
// Maybe also support an option to force the click to happen on a specific part of the element (checkbox or row). That way
// the user can test a specific type of interaction?
toggleRowSelection = async (opts: {index?: number, text?: string, interactionType?: UserOpts['interactionType']} = {}) => {
/**
* Toggles the selection for the specified gridlist row. Defaults to using the interaction type set on the gridlist tester.
*/
async toggleRowSelection(opts: GridListToggleRowOpts = {}) {
let {index, text, interactionType = this._interactionType} = opts;

let row = this.findRow({index, text});
Expand All @@ -50,11 +88,14 @@ export class GridListTester {
let cell = within(row).getAllByRole('gridcell')[0];
await pressElement(this.user, cell, interactionType);
}
};
}

// TODO: pretty much the same as table except it uses this.gridlist. Make common between the two by accepting an option for
// an element?
findRow = (opts: {index?: number, text?: string}) => {
/**
* Returns a row matching the specified index or text content.
*/
findRow(opts: {index?: number, text?: string}) {
let {
index,
text
Expand All @@ -71,11 +112,14 @@ export class GridListTester {
}

return row;
};
}

// TODO: There is a more difficult use case where the row has/behaves as link, don't think we have a good way to determine that unless the
// user specificlly tells us
triggerRowAction = async (opts: {index?: number, text?: string, needsDoubleClick?: boolean, interactionType?: UserOpts['interactionType']}) => {
/**
* Triggers the action for the specified gridlist row. Defaults to using the interaction type set on the gridlist tester.
*/
async triggerRowAction(opts: GridListRowActionOpts) {
let {
index,
text,
Expand All @@ -94,23 +138,35 @@ export class GridListTester {
await pressElement(this.user, row, interactionType);
}
}
};
}

// TODO: do we really need this getter? Theoretically the user already has the reference to the gridlist
get gridlist() {
/**
* Returns the gridlist.
*/
get gridlist(): HTMLElement {
return this._gridlist;
}

get rows() {
/**
* Returns the gridlist's rows if any.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these 'any' are fine because the array could be empty

*/
get rows(): HTMLElement[] {
return within(this?.gridlist).queryAllByRole('row');
}

get selectedRows() {
/**
* Returns the gridlist's selected rows if any.
*/
get selectedRows(): HTMLElement[] {
return this.rows.filter(row => row.getAttribute('aria-selected') === 'true');
}

cells = (opts: {element?: HTMLElement} = {}) => {
let {element} = opts;
return within(element || this.gridlist).queryAllByRole('gridcell');
};
/**
* Returns the gridlist's cells if any. Can be filtered against a specific row if provided via `element`.
*/
cells(opts: {element?: HTMLElement} = {}): HTMLElement[] {
let {element = this.gridlist} = opts;
return within(element).queryAllByRole('gridcell');
}
}
2 changes: 1 addition & 1 deletion packages/@react-aria/test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ export {installMouseEvent, installPointerEvent} from './testSetup';
export {pointerMap} from './userEventMaps';
export {User} from './user';

export type {UserOpts} from './user';
export type {UserOpts} from './types';
Loading