Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^15.0.7",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch",
"@types/react": "npm:types-react@19.0.0-rc.0",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.0",
Expand Down
5 changes: 3 additions & 2 deletions packages/@react-aria/test-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"@testing-library/react": "^15.0.7",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.0.0",
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
},
"publishConfig": {
"access": "public"
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/test-utils/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const DEFAULT_LONG_PRESS_TIME = 500;
* @param opts.advanceTimer - Function that when called advances the timers in your test suite by a specific amount of time(ms).
* @param opts.pointeropts - Options to pass to the simulated event. Defaults to mouse. See https://testing-library.com/docs/dom-testing-library/api-events/#fireevent for more info.
*/
export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer: (time?: number) => void | Promise<unknown>, pointerOpts?: Record<string, any>}): Promise<void> {
export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer: (time: number) => unknown | Promise<unknown>, pointerOpts?: Record<string, any>}): Promise<void> {
// TODO: note that this only works if the code from installPointerEvent is called somewhere in the test BEFORE the
// render. Perhaps we should rely on the user setting that up since I'm not sure there is a great way to set that up here in the
// util before first render. Will need to document it well
Expand Down
9 changes: 5 additions & 4 deletions packages/@react-aria/test-utils/src/gridlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export class GridListTester {
if (targetIndex === -1) {
throw new Error('Option provided is not in the gridlist');
}

if (document.activeElement !== this._gridlist || !this._gridlist.contains(document.activeElement)) {
act(() => this._gridlist.focus());
}

if (document.activeElement === this._gridlist) {
await this.user.keyboard('[ArrowDown]');
} else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') {
Expand Down Expand Up @@ -161,10 +166,6 @@ export class GridListTester {
return;
}

if (document.activeElement !== this._gridlist || !this._gridlist.contains(document.activeElement)) {
act(() => this._gridlist.focus());
}

await this.keyboardNavigateToRow({row});
await this.user.keyboard('[Enter]');
} else {
Expand Down
14 changes: 4 additions & 10 deletions packages/@react-aria/test-utils/src/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ export class ListBoxTester {
throw new Error('Option provided is not in the listbox');
}

if (document.activeElement === this._listbox) {
await this.user.keyboard('[ArrowDown]');
if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) {
act(() => this._listbox.focus());
}

await this.user.keyboard('[ArrowDown]');

// TODO: not sure about doing same while loop that exists in other implementations of keyboardNavigateToOption,
// feels like it could break easily
if (document.activeElement?.getAttribute('role') !== 'option') {
Expand Down Expand Up @@ -135,10 +137,6 @@ export class ListBoxTester {
return;
}

if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) {
act(() => this._listbox.focus());
}

await this.keyboardNavigateToOption({option});
await this.user.keyboard(`[${keyboardActivation}]`);
} else {
Expand Down Expand Up @@ -179,10 +177,6 @@ export class ListBoxTester {
return;
}

if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) {
act(() => this._listbox.focus());
}

await this.keyboardNavigateToOption({option});
await this.user.keyboard('[Enter]');
} else {
Expand Down
70 changes: 60 additions & 10 deletions packages/@react-aria/test-utils/src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ export class MenuTester {
private _advanceTimer: UserOpts['advanceTimer'];
private _trigger: HTMLElement | undefined;
private _isSubmenu: boolean = false;
private _rootMenu: HTMLElement | undefined;

constructor(opts: MenuTesterOpts) {
let {root, user, interactionType, advanceTimer, isSubmenu} = opts;
let {root, user, interactionType, advanceTimer, isSubmenu, rootMenu} = opts;
this.user = user;
this._interactionType = interactionType || 'mouse';
this._advanceTimer = advanceTimer;
Expand All @@ -85,6 +86,7 @@ export class MenuTester {
}

this._isSubmenu = isSubmenu || false;
this._rootMenu = rootMenu;
}

/**
Expand Down Expand Up @@ -226,20 +228,56 @@ export class MenuTester {
await this.user.pointer({target: option, keys: '[TouchA]'});
}
}
act(() => {jest.runAllTimers();});

if (option.getAttribute('href') == null && option.getAttribute('aria-haspopup') == null && menuSelectionMode === 'single' && closesOnSelect && keyboardActivation !== 'Space' && !this._isSubmenu) {
// 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
// the menu option select may trigger.
if (
!(menuSelectionMode === 'single' && !closesOnSelect) &&
!(menuSelectionMode === 'multiple' && (keyboardActivation === 'Space' || interactionType === 'mouse'))
) {
Comment on lines +232 to +237
Copy link
Member Author

Choose a reason for hiding this comment

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

The chain of waitFors below is a bit gross, but allows us to get rid of any assumptions of how long it will take the menu to close/transition out. Open to any alternatives

Copy link
Member

Choose a reason for hiding this comment

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

I think it might be better to pass in a function which could advance the timers by whatever amount.
I'm not sure how waitFor works in a mocked timer situation since technically it does polling which is based on timers.... at least, in the non-mocked timers case. You may want to have a look at the source for waitFor.
The alternative is to do something like test-library does with their userSetup({delay: null/50/100/whatever})

Copy link
Member

@snowystinger snowystinger Apr 3, 2025

Choose a reason for hiding this comment

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

selectOption({option, timerForClose: () => act(() => jest.runAllTimers())})

happy to workshop the name

Copy link
Member Author

Choose a reason for hiding this comment

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

waitFor advances fake timers if it detects them via: https://github.com/testing-library/dom-testing-library/blob/a86c54ccda5242ad8dfc1c70d31980bdbf96af7f/src/wait-for.js#L75-L82 and does polling. The only thing I'm a bit wary about with regards to making the user pass the timerForClose is twofold:

  • they may not really know how long they would need to wait (they may be using a third party library/have other conditions that may introduce variable timings to said menu closure). They could of course finagle that timing but I do like how using waitFor under the hood reduces the burden on the user to get the timing right.
  • API consistency: introducing timerForClose may open the need/possibility of adding the same kind of api to many of the other interaction calls in the utils. Perhaps it would be best for the user to perform their own polling after calling selectOption instead? Perhaps we could expect that they should await focus to return to a specific element outside of the util instead

Copy link
Member

Choose a reason for hiding this comment

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

Ah, didn't know it would advance fakeTimers, that's great, then I think this is fine

// 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
// for focus to be coerced to somewhere else in place of running all timers.
if (this._isSubmenu) {
await waitFor(() => {
if (document.activeElement === document.body) {
throw new Error('Expected focus to move to somewhere other than the body after selecting a submenu option.');
} else {
return true;
}
});
}

// If user isn't trying to select multiple menu options or closeOnSelect is true then we can assume that
// the menu will close or some action is triggered. In cases like that focus should move somewhere after the menu closes
// but we can't really know where so just make sure it doesn't get lost to the body.
await waitFor(() => {
if (document.activeElement !== trigger) {
throw new Error(`Expected the document.activeElement after selecting an option to be the menu trigger but got ${document.activeElement}`);
if (document.activeElement === option) {
throw new Error('Expected focus after selecting an option to move away from the option.');
} else {
return true;
}
});

if (document.contains(menu)) {
throw new Error('Expected menu element to not be in the document after selecting an option');
// We'll also want to wait for focus to move away from the original submenu trigger since the entire submenu tree should
// 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
if (this._isSubmenu) {
await waitFor(() => {
if (document.activeElement === this.trigger || this._rootMenu?.contains(document.activeElement)) {
throw new Error('Expected focus after selecting an submenu option to move away from the original submenu trigger.');
} else {
return true;
}
});
}

// Finally wait for focus to be coerced somewhere final when the menu tree is removed from the DOM
await waitFor(() => {
if (document.activeElement === document.body) {
throw new Error('Expected focus to move to somewhere other than the body after selecting a menu option.');
} else {
return true;
}
});
}
} else {
throw new Error("Attempted to select a option in the menu, but menu wasn't found.");
Expand Down Expand Up @@ -269,18 +307,30 @@ export class MenuTester {
submenuTrigger = (within(menu!).getByText(submenuTrigger).closest('[role=menuitem]'))! as HTMLElement;
}

let submenuTriggerTester = new MenuTester({user: this.user, interactionType: this._interactionType, root: submenuTrigger, isSubmenu: true});
let submenuTriggerTester = new MenuTester({
user: this.user,
interactionType: this._interactionType,
root: submenuTrigger,
isSubmenu: true,
advanceTimer: this._advanceTimer,
rootMenu: (this._isSubmenu ? this._rootMenu : this.menu) || undefined
});
if (interactionType === 'mouse') {
await this.user.pointer({target: submenuTrigger});
act(() => {jest.runAllTimers();});
} else if (interactionType === 'keyboard') {
await this.keyboardNavigateToOption({option: submenuTrigger});
await this.user.keyboard('[ArrowRight]');
act(() => {jest.runAllTimers();});
} else {
await submenuTriggerTester.open();
}

await waitFor(() => {
if (submenuTriggerTester._trigger?.getAttribute('aria-expanded') !== 'true') {
throw new Error('aria-expanded for the submenu trigger wasn\'t changed to "true", unable to confirm the existance of the submenu');
} else {
return true;
}
});

return submenuTriggerTester;
}
Expand Down
9 changes: 5 additions & 4 deletions packages/@react-aria/test-utils/src/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export class TreeTester {
if (targetIndex === -1) {
throw new Error('Option provided is not in the tree');
}

if (document.activeElement !== this._tree || !this._tree.contains(document.activeElement)) {
act(() => this._tree.focus());
}

if (document.activeElement === this.tree) {
await this.user.keyboard('[ArrowDown]');
} else if (this._tree.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') {
Expand Down Expand Up @@ -206,10 +211,6 @@ export class TreeTester {
return;
}

if (document.activeElement !== this._tree || !this._tree.contains(document.activeElement)) {
act(() => this._tree.focus());
}

await this.keyboardNavigateToRow({row});
await this.user.keyboard('[Enter]');
} else {
Expand Down
12 changes: 8 additions & 4 deletions packages/@react-aria/test-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ export interface UserOpts {
* @default mouse
*/
interactionType?: 'mouse' | 'touch' | 'keyboard',
// If using fake timers user should provide something like (time) => jest.advanceTimersByTime(time))}
// A real timer user would pass async () => await new Promise((resolve) => setTimeout(resolve, waitTime))
// If using fake timers user should provide something like (time) => jest.advanceTimersByTime(time))}.
// A real timer user would pass (waitTime) => new Promise((resolve) => setTimeout(resolve, waitTime))
// Time is in ms.
/**
* A function used by the test utils to advance timers during interactions. Required for certain aria patterns (e.g. table). This can be overridden
* at the aria pattern tester level if needed.
*/
advanceTimer?: (time?: number) => void | Promise<unknown>
advanceTimer?: (time: number) => unknown | Promise<unknown>
}

export interface BaseTesterOpts extends UserOpts {
Expand Down Expand Up @@ -69,7 +69,11 @@ export interface MenuTesterOpts extends BaseTesterOpts {
/**
* Whether the current menu is a submenu.
*/
isSubmenu?: boolean
isSubmenu?: boolean,
/**
* The root menu of the menu tree. Only available if the menu is a submenu.
*/
rootMenu?: HTMLElement
}

export interface SelectTesterOpts extends BaseTesterOpts {
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/test-utils/src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ type TesterOpts<T> =
T extends 'Tree' ? TreeTesterOpts :
never;

let defaultAdvanceTimer = async (waitTime: number | undefined) => await new Promise((resolve) => setTimeout(resolve, waitTime));
let defaultAdvanceTimer = (waitTime: number | undefined) => new Promise((resolve) => setTimeout(resolve, waitTime));

export class User {
private user;
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/combobox/docs/ComboBox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1006,7 +1006,7 @@ import {theme} from '@react-spectrum/theme-default';
import {User} from '@react-spectrum/test-utils';

let testUtilUser = new User({interactionType: 'mouse'});
// ...
// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/ComboBox.html#testing

it('ComboBox can select an option via keyboard', async function () {
// Render your test component/app and initialize the combobox tester
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/list/docs/ListView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1205,7 +1205,7 @@ import {theme} from '@react-spectrum/theme-default';
import {User} from '@react-spectrum/test-utils';

let testUtilUser = new User({interactionType: 'mouse'});
// ...
// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/ListView.html#testing

it('ListView can select a row via keyboard', async function () {
// Render your test component/app and initialize the gridlist tester
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/listbox/docs/ListBox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ import {theme} from '@react-spectrum/theme-default';
import {User} from '@react-spectrum/test-utils';

let testUtilUser = new User({interactionType: 'mouse'});
// ...
// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/ListBox.html#testing
Copy link
Member Author

Choose a reason for hiding this comment

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

Hopefully makes it a little more clear that additional setup might be needed for RSP components in general


it('ListBox can select an option via keyboard', async function () {
// Render your test component/app and initialize the listbox tester
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/menu/docs/MenuTrigger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ import {theme} from '@react-spectrum/theme-default';
import {User} from '@react-spectrum/test-utils';

let testUtilUser = new User({interactionType: 'mouse'});
// ...
// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/MenuTrigger.html#testing

it('Menu can open its submenu via keyboard', async function () {
// Render your test component/app and initialize the menu tester
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/picker/docs/Picker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ import {theme} from '@react-spectrum/theme-default';
import {User} from '@react-spectrum/test-utils';

let testUtilUser = new User({interactionType: 'mouse'});
// ...
// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/Picker.html#testing

it('Picker can select an option via keyboard', async function () {
// Render your test component/app and initialize the select tester
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"@parcel/macros": "^2.14.0",
"@react-aria/test-utils": "1.0.0-alpha.3",
"@testing-library/dom": "^10.1.0",
"@testing-library/react": "^15.0.7",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.0.0",
"jest": "^29.5.0"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/table/docs/TableView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1974,7 +1974,7 @@ import {theme} from '@react-spectrum/theme-default';
import {User} from '@react-spectrum/test-utils';

let testUtilUser = new User({interactionType: 'mouse', advanceTimer: jest.advanceTimersByTime});
// ...
// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/TableView.html#testing

it('TableView can toggle row selection', async function () {
// Render your test component/app and initialize the table tester
Expand Down
Loading