diff --git a/packages/@react-aria/test-utils/package.json b/packages/@react-aria/test-utils/package.json index e2a3c362b37..f0b5026a95b 100644 --- a/packages/@react-aria/test-utils/package.json +++ b/packages/@react-aria/test-utils/package.json @@ -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", "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" }, "publishConfig": { diff --git a/packages/@react-aria/test-utils/src/combobox.ts b/packages/@react-aria/test-utils/src/combobox.ts index ede87adc37f..f3d4e2cd6d6 100644 --- a/packages/@react-aria/test-utils/src/combobox.ts +++ b/packages/@react-aria/test-utils/src/combobox.ts @@ -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'; @@ -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; @@ -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}); @@ -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()); @@ -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 { 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; } } diff --git a/packages/@react-aria/test-utils/src/events.ts b/packages/@react-aria/test-utils/src/events.ts index ae1e16dfcf7..6683cbc60f4 100644 --- a/packages/@react-aria/test-utils/src/events.ts +++ b/packages/@react-aria/test-utils/src/events.ts @@ -11,7 +11,7 @@ */ import {act, fireEvent} from '@testing-library/react'; -import {UserOpts} from './user'; +import {UserOpts} from './types'; export const DEFAULT_LONG_PRESS_TIME = 500; diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index 7043c97d32b..713da7b2b92 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -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, + /** + * 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}); @@ -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 @@ -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, @@ -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. + */ + 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'); + } } diff --git a/packages/@react-aria/test-utils/src/index.ts b/packages/@react-aria/test-utils/src/index.ts index 962abbf72c3..b5b7da34492 100644 --- a/packages/@react-aria/test-utils/src/index.ts +++ b/packages/@react-aria/test-utils/src/index.ts @@ -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'; diff --git a/packages/@react-aria/test-utils/src/menu.ts b/packages/@react-aria/test-utils/src/menu.ts index 68f2a948cb3..5f4de1b8be5 100644 --- a/packages/@react-aria/test-utils/src/menu.ts +++ b/packages/@react-aria/test-utils/src/menu.ts @@ -11,19 +11,59 @@ */ import {act, waitFor, within} from '@testing-library/react'; -import {BaseTesterOpts, UserOpts} from './user'; +import {MenuTesterOpts, UserOpts} from './types'; import {triggerLongPress} from './events'; -export interface MenuOptions extends UserOpts, BaseTesterOpts { - user: any +interface MenuOpenOpts { + /** + * Whether the menu needs to be long pressed to open. + */ + needsLongPress?: boolean, + /** + * What interaction type to use when opening the menu. Defaults to the interaction type set on the tester. + */ + interactionType?: UserOpts['interactionType'] } + +interface MenuSelectOpts extends MenuOpenOpts { + /** + * 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, + /** + * The menu's selection mode. Will affect whether or not the menu is expected to be closed upon option selection. + * @default 'single' + */ + menuSelectionMode?: 'single' | 'multiple', + /** + * Whether or not the menu closes on select. Depends on menu implementation and configuration. + * @default true + */ + closesOnSelect?: boolean +} + +interface MenuOpenSubmenuOpts extends MenuOpenOpts { + /** + * The submenu trigger to open. Available submenu trigger nodes can be sourced via `submenuTriggers`. + */ + submenuTrigger?: HTMLElement, + /** + * The text of submenu trigger to open. Alternative to `submenuTrigger`. + */ + submenuTriggerText?: string +} + export class MenuTester { private user; private _interactionType: UserOpts['interactionType']; private _advanceTimer: UserOpts['advanceTimer']; private _trigger: HTMLElement; - constructor(opts: MenuOptions) { + constructor(opts: MenuTesterOpts) { let {root, user, interactionType, advanceTimer} = opts; this.user = user; this._interactionType = interactionType || 'mouse'; @@ -43,13 +83,19 @@ export class MenuTester { } } - setInteractionType = (type: UserOpts['interactionType']) => { + /** + * Set the interaction type used by the menu tester. + */ + setInteractionType(type: UserOpts['interactionType']) { this._interactionType = type; - }; + } // 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 // One difference will be that it supports long press as well - open = async (opts: {needsLongPress?: boolean, interactionType?: UserOpts['interactionType']} = {}) => { + /** + * Opens the menu. Defaults to using the interaction type set on the menu tester. + */ + async open(opts: MenuOpenOpts = {}) { let { needsLongPress, interactionType = this._interactionType @@ -91,11 +137,15 @@ export class MenuTester { } }); } - }; + } // TODO: also very similar to select, barring potential long press support // Close on select is also kinda specific? - selectOption = async (opts: {option?: HTMLElement, optionText?: string, menuSelectionMode?: 'single' | 'multiple', needsLongPress?: boolean, closesOnSelect?: boolean, interactionType?: UserOpts['interactionType']}) => { + /** + * Selects the desired menu option. Defaults to using the interaction type set on the menu tester. If necessary, will open the menu dropdown beforehand. + * The desired option can be targeted via the option's node or the option's text. + */ + async selectOption(opts: MenuSelectOpts) { let { optionText, menuSelectionMode = 'single', @@ -146,10 +196,13 @@ export class MenuTester { } else { throw new Error("Attempted to select a option in the menu, but menu wasn't found."); } - }; + } // TODO: update this to remove needsLongPress if we wanna make the user call open first always - openSubmenu = async (opts: {submenuTrigger?: HTMLElement, submenuTriggerText?: string, needsLongPress?: boolean, interactionType?: UserOpts['interactionType']}): Promise => { + /** + * 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. + */ + async openSubmenu(opts: MenuOpenSubmenuOpts): Promise { let { submenuTrigger, submenuTriggerText, @@ -179,9 +232,12 @@ export class MenuTester { } return null; - }; + } - close = async () => { + /** + * Closes the menu. + */ + async close() { let menu = this.menu; if (menu) { act(() => menu.focus()); @@ -199,26 +255,47 @@ export class MenuTester { throw new Error('Expected the menu to not be in the document after closing it.'); } } - }; + } - get trigger() { + /** + * Returns the menu's trigger. + */ + get trigger(): HTMLElement { return this._trigger; } - get menu() { + /** + * Returns the menu if present. + */ + get menu(): HTMLElement | null { let menuId = this.trigger.getAttribute('aria-controls'); - return menuId ? document.getElementById(menuId) : undefined; + return menuId ? document.getElementById(menuId) : null; } - get options(): HTMLElement[] | never[] { + /** + * Returns the menu's sections if any. + */ + get sections(): HTMLElement[] { let menu = this.menu; - let options = []; if (menu) { - options = within(menu).queryAllByRole('menuitem'); + return within(menu).queryAllByRole('group'); + } else { + return []; + } + } + + /** + * Returns the menu's options if present. Can be filtered to a subsection of the menu if provided via `element`. + */ + options(opts: {element?: HTMLElement} = {}): HTMLElement[] { + let {element = this.menu} = opts; + let options = []; + if (element) { + options = within(element).queryAllByRole('menuitem'); if (options.length === 0) { - options = within(menu).queryAllByRole('menuitemradio'); + options = within(element).queryAllByRole('menuitemradio'); if (options.length === 0) { - options = within(menu).queryAllByRole('menuitemcheckbox'); + options = within(element).queryAllByRole('menuitemcheckbox'); } } } @@ -226,19 +303,13 @@ export class MenuTester { return options; } - get sections() { - let menu = this.menu; - if (menu) { - return within(menu).queryAllByRole('group'); - } else { - return []; - } - } - - get submenuTriggers() { - let options = this.options; + /** + * Returns the menu's submenu triggers if any. + */ + get submenuTriggers(): HTMLElement[] { + let options = this.options(); if (options.length > 0) { - return this.options.filter(item => item.getAttribute('aria-haspopup') != null); + return options.filter(item => item.getAttribute('aria-haspopup') != null); } return []; diff --git a/packages/@react-aria/test-utils/src/select.ts b/packages/@react-aria/test-utils/src/select.ts index 0773d55ccb3..c8c5a4d29af 100644 --- a/packages/@react-aria/test-utils/src/select.ts +++ b/packages/@react-aria/test-utils/src/select.ts @@ -11,18 +11,32 @@ */ import {act, waitFor, within} from '@testing-library/react'; -import {BaseTesterOpts, UserOpts} from './user'; +import {SelectTesterOpts, UserOpts} from './types'; -export interface SelectOptions extends UserOpts, BaseTesterOpts { - // 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 - user: any +interface SelectOpenOpts { + /** + * What interaction type to use when opening the select. Defaults to the interaction type set on the tester. + */ + interactionType?: UserOpts['interactionType'] } + +interface SelectTriggerOptionOpts extends SelectOpenOpts { + /** + * 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 SelectTester { private user; private _interactionType: UserOpts['interactionType']; private _trigger: HTMLElement; - constructor(opts: SelectOptions) { + constructor(opts: SelectTesterOpts) { let {root, user, interactionType} = opts; this.user = user; this._interactionType = interactionType || 'mouse'; @@ -33,12 +47,17 @@ export class SelectTester { } this._trigger = triggerButton; } - - setInteractionType = (type: UserOpts['interactionType']) => { + /** + * Set the interaction type used by the select tester. + */ + setInteractionType(type: UserOpts['interactionType']) { this._interactionType = type; - }; + } - open = async (opts: {interactionType?: UserOpts['interactionType']} = {}) => { + /** + * Opens the select. Defaults to using the interaction type set on the select tester. + */ + async open(opts: SelectOpenOpts = {}) { let { interactionType = this._interactionType } = opts; @@ -69,11 +88,40 @@ export class SelectTester { return true; } }); - }; + } + + /** + * Closes the select. + */ + async close() { + let listbox = this.listbox; + if (listbox) { + act(() => listbox.focus()); + await this.user.keyboard('[Escape]'); + } + + await waitFor(() => { + if (document.activeElement !== this._trigger) { + throw new Error(`Expected the document.activeElement after closing the select dropdown to be the select component trigger but got ${document.activeElement}`); + } else { + return true; + } + }); - selectOption = async (opts: {optionText: string, interactionType?: UserOpts['interactionType']}) => { + if (listbox && document.contains(listbox)) { + throw new Error('Expected the select element listbox to not be in the document after closing the dropdown.'); + } + } + + // TODO: update this so it also can take the option node instead of just text, might already have been added in Rob's PR + /** + * Selects the desired select option. Defaults to using the interaction type set on the select tester. If necessary, will open the select dropdown beforehand. + * The desired option can be targeted via the option's text. + */ + async selectOption(opts: SelectTriggerOptionOpts) { let { optionText, + option, interactionType = this._interactionType } = opts || {}; let trigger = this.trigger; @@ -82,7 +130,10 @@ export class SelectTester { } let listbox = this.listbox; if (listbox) { - let option = within(listbox).getByText(optionText); + if (!option && optionText) { + option = within(listbox).getByText(optionText); + } + if (interactionType === 'keyboard') { if (document.activeElement !== listbox || !listbox.contains(document.activeElement)) { act(() => listbox.focus()); @@ -101,7 +152,7 @@ export class SelectTester { } } - if (option.getAttribute('href') == null) { + if (option?.getAttribute('href') == null) { await waitFor(() => { if (document.activeElement !== this._trigger) { throw new Error(`Expected the document.activeElement after selecting an option to be the select component trigger but got ${document.activeElement}`); @@ -115,43 +166,40 @@ export class SelectTester { } } } - }; + } - close = async () => { - let listbox = this.listbox; - if (listbox) { - act(() => listbox.focus()); - await this.user.keyboard('[Escape]'); + /** + * Returns the select'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'); } - await waitFor(() => { - if (document.activeElement !== this._trigger) { - throw new Error(`Expected the document.activeElement after closing the select dropdown to be the select component trigger but got ${document.activeElement}`); - } else { - return true; - } - }); - - if (listbox && document.contains(listbox)) { - throw new Error('Expected the select element listbox to not be in the document after closing the dropdown.'); - } - }; + return options; + } - get trigger() { + /** + * Returns the select's trigger. + */ + get trigger(): HTMLElement { return this._trigger; } - get listbox() { + /** + * Returns the select's listbox if present. + */ + get listbox(): HTMLElement | null { let listBoxId = this.trigger.getAttribute('aria-controls'); - return listBoxId ? document.getElementById(listBoxId) : undefined; - } - - get options() { - let listbox = this.listbox; - return listbox ? within(listbox).queryAllByRole('option') : []; + return listBoxId ? document.getElementById(listBoxId) : null; } - get sections() { + /** + * Returns the select's sections if present. + */ + get sections(): HTMLElement[] { let listbox = this.listbox; return listbox ? within(listbox).queryAllByRole('group') : []; } diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 1f019706ae3..9d52af082a4 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -11,23 +11,70 @@ */ import {act, fireEvent, waitFor, within} from '@testing-library/react'; -import {BaseTesterOpts, UserOpts} from './user'; import {pressElement, triggerLongPress} from './events'; -export interface TableOptions extends UserOpts, BaseTesterOpts { - user: any, - advanceTimer: UserOpts['advanceTimer'] +import {TableTesterOpts, UserOpts} from './types'; + +// TODO: this is a bit inconsistent from combobox, perhaps should also take node or combobox should also have find row +interface TableToggleRowOpts { + /** + * The index of the row to toggle selection for. + */ + index?: number, + /** + * The text of the row to toggle selection for. Alternative to `index`. + */ + text?: string, + /** + * Whether the row needs to be long pressed to be selected. Depends on the table's implementation. + */ + needsLongPress?: boolean, + /** + * What interaction type to use when toggling the row selection. Defaults to the interaction type set on the tester. + */ + interactionType?: UserOpts['interactionType'] +} + +interface TableToggleSortOpts { + /** + * The index of the column to sort. + */ + index?: number, + /** + * The text of the column to sort. Alternative to `index`. + */ + text?: string, + /** + * What interaction type to use when sorting the column. Defaults to the interaction type set on the tester. + */ + interactionType?: UserOpts['interactionType'] +} + +interface TableRowActionOpts { + /** + * 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 table needs a double click to trigger the row action. Depends on the table's implementation. + */ + needsDoubleClick?: boolean } -// TODO: Previously used logic like https://github.com/testing-library/react-testing-library/blame/c63b873072d62c858959c2a19e68f8e2cc0b11be/src/pure.js#L16 -// but https://github.com/testing-library/dom-testing-library/issues/987#issuecomment-891901804 indicates that it may falsely indicate that fake timers are enabled -// when they aren't export class TableTester { private user; private _interactionType: UserOpts['interactionType']; private _advanceTimer: UserOpts['advanceTimer']; private _table: HTMLElement; - constructor(opts: TableOptions) { + constructor(opts: TableTesterOpts) { let {root, user, interactionType, advanceTimer} = opts; this.user = user; this._interactionType = interactionType || 'mouse'; @@ -35,11 +82,17 @@ export class TableTester { this._table = root; } - setInteractionType = (type: UserOpts['interactionType']) => { + /** + * Set the interaction type used by the table tester. + */ + setInteractionType(type: UserOpts['interactionType']) { this._interactionType = type; - }; + } - toggleRowSelection = async (opts: {index?: number, text?: string, needsLongPress?: boolean, interactionType?: UserOpts['interactionType']} = {}) => { + /** + * Toggles the selection for the specified table row. Defaults to using the interaction type set on the table tester. + */ + async toggleRowSelection(opts: TableToggleRowOpts = {}) { let { index, text, @@ -77,9 +130,12 @@ export class TableTester { await this._advanceTimer(200); }); - }; + } - toggleSort = async (opts: {index?: number, text?: string, interactionType?: UserOpts['interactionType']} = {}) => { + /** + * Toggles the sort order for the specified table column. Defaults to using the interaction type set on the table tester. + */ + async toggleSort(opts: TableToggleSortOpts = {}) { let { index, text, @@ -163,11 +219,14 @@ export class TableTester { } else { await pressElement(this.user, columnheader, interactionType); } - }; + } // TODO: should there be a util for triggering a row action? Perhaps there should be but it would rely on the user teling us the config of the // table. Maybe we could rely on the user knowing to trigger a press/double click? We could have the user pass in "needsDoubleClick" // It is also iffy if there is any row selected because then the table is in selectionMode and the below actions will simply toggle row selection - triggerRowAction = async (opts: {index?: number, text?: string, needsDoubleClick?: boolean, interactionType?: UserOpts['interactionType']} = {}) => { + /** + * Triggers the action for the specified table row. Defaults to using the interaction type set on the table tester. + */ + async triggerRowAction(opts: TableRowActionOpts = {}) { let { index, text, @@ -186,15 +245,17 @@ export class TableTester { await pressElement(this.user, row, interactionType); } } - }; + } // TODO: should there be utils for drag and drop and column resizing? For column resizing, I'm not entirely convinced that users will be doing that in their tests. // For DnD, it might be tricky to do for keyboard DnD since we wouldn't know what valid drop zones there are... Similarly, for simulating mouse drag and drop the coordinates depend // on the mocks the user sets up for their row height/etc. // Additionally, should we also support keyboard navigation/typeahead? Those felt like they could be very easily replicated by the user via user.keyboard already and don't really // add much value if we provide that to them - - toggleSelectAll = async (opts: {interactionType?: UserOpts['interactionType']} = {}) => { + /** + * Toggle selection for all rows in the table. Defaults to using the interaction type set on the table tester. + */ + async toggleSelectAll(opts: {interactionType?: UserOpts['interactionType']} = {}) { let { interactionType = this._interactionType } = opts; @@ -205,9 +266,12 @@ export class TableTester { } else { await pressElement(this.user, checkbox, interactionType); } - }; + } - 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 @@ -226,9 +290,12 @@ export class TableTester { } return row; - }; + } - findCell = (opts: {text: string}) => { + /** + * Returns a cell matching the specified text content. + */ + findCell(opts: {text: string}) { let { text } = opts; @@ -245,38 +312,58 @@ export class TableTester { } return cell; - }; + } - get table() { + /** + * Returns the table. + */ + get table(): HTMLElement { return this._table; } - get rowGroups() { + /** + * Returns the row groups within the table. + */ + get rowGroups(): HTMLElement[] { let table = this._table; return table ? within(table).queryAllByRole('rowgroup') : []; } - get columns() { + /** + * Returns the columns within the table. + */ + get columns(): HTMLElement[] { let headerRowGroup = this.rowGroups[0]; return headerRowGroup ? within(headerRowGroup).queryAllByRole('columnheader') : []; } - get rows() { + /** + * Returns the rows within the table if any. + */ + get rows(): HTMLElement[] { let bodyRowGroup = this.rowGroups[1]; return bodyRowGroup ? within(bodyRowGroup).queryAllByRole('row') : []; } - get selectedRows() { + /** + * Returns the currently selected rows within the table if any. + */ + get selectedRows(): HTMLElement[] { return this.rows.filter(row => row.getAttribute('aria-selected') === 'true'); } - get rowHeaders() { - let table = this.table; - return table ? within(table).queryAllByRole('rowheader') : []; + /** + * Returns the row headers within the table if any. + */ + get rowHeaders(): HTMLElement[] { + return within(this.table).queryAllByRole('rowheader'); } - get cells() { - let table = this.table; - return table ? within(table).queryAllByRole('gridcell') : []; + /** + * Returns the cells within the table if any. Can be filtered against a specific row if provided via `element`. + */ + cells(opts: {element?: HTMLElement} = {}): HTMLElement[] { + let {element = this.table} = opts; + return within(element).queryAllByRole('gridcell'); } } diff --git a/packages/@react-aria/test-utils/src/types.ts b/packages/@react-aria/test-utils/src/types.ts new file mode 100644 index 00000000000..619095c74f5 --- /dev/null +++ b/packages/@react-aria/test-utils/src/types.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// https://github.com/testing-library/dom-testing-library/issues/939#issuecomment-830771708 is an interesting way of allowing users to configure the timers +// curent way is like https://testing-library.com/docs/user-event/options/#advancetimers, +export interface UserOpts { + /** + * The interaction type (mouse, touch, keyboard) that the test util user will use when interacting with a component. This can be overridden + * at the aria pattern tester level if needed. + * @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)) + // 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 +} + +export interface BaseTesterOpts { + /** The base element for the given tester (e.g. the table, menu trigger button, etc). */ + root: HTMLElement +} + +export interface ComboBoxTesterOpts extends UserOpts, BaseTesterOpts { + /** @private */ + user: any, + /** + * The base element for the combobox. If provided the wrapping element around the target combobox (as is the the case with a ref provided to RSP ComboBox), + * will automatically search for the combobox element within. + */ + root: HTMLElement, + /** + * The node of the combobox trigger button if any. If not provided, we will try to automatically use any button + * within the `root` provided or that the `root` serves as the trigger. + */ + trigger?: HTMLElement +} + +export interface GridListTesterOpts extends UserOpts, BaseTesterOpts { + /** @private */ + user: any +} + +export interface MenuTesterOpts extends UserOpts, BaseTesterOpts { + /** @private */ + user: any, + /** + * The trigger element for the menu. + */ + root: HTMLElement +} + +export interface SelectTesterOpts extends UserOpts, BaseTesterOpts { + /** @private */ + user: any, + /** + * The trigger element for the select. If provided the wrapping element around the target select (as is the case with a ref provided to RSP Select), + * will automatically search for the select's trigger element within. + */ + root: HTMLElement +} + +export interface TableTesterOpts extends UserOpts, BaseTesterOpts { + /** @private */ + user: any, + /** + * A function used by the test utils to advance timers during interactions. + */ + advanceTimer: UserOpts['advanceTimer'] +} diff --git a/packages/@react-aria/test-utils/src/user.ts b/packages/@react-aria/test-utils/src/user.ts index 8f87e30d529..6f515da7ece 100644 --- a/packages/@react-aria/test-utils/src/user.ts +++ b/packages/@react-aria/test-utils/src/user.ts @@ -10,54 +10,48 @@ * governing permissions and limitations under the License. */ -import {ComboBoxOptions, ComboBoxTester} from './combobox'; -import {GridListOptions, GridListTester} from './gridlist'; -import {MenuOptions, MenuTester} from './menu'; +import {ComboBoxTester} from './combobox'; +import {ComboBoxTesterOpts, GridListTesterOpts, MenuTesterOpts, SelectTesterOpts, TableTesterOpts, UserOpts} from './types'; +import {GridListTester} from './gridlist'; +import {MenuTester} from './menu'; import {pointerMap} from './'; -import {SelectOptions, SelectTester} from './select'; -import {TableOptions, TableTester} from './table'; +import {SelectTester} from './select'; +import {TableTester} from './table'; import userEvent from '@testing-library/user-event'; -// https://github.com/testing-library/dom-testing-library/issues/939#issuecomment-830771708 is an interesting way of allowing users to configure the timers -// curent way is like https://testing-library.com/docs/user-event/options/#advancetimers, -export interface UserOpts { - 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)) - // Time is in ms. - advanceTimer?: (time?: number) => void | Promise -} - -export interface BaseTesterOpts { - // The base element for the given tester (e.g. the table, menu trigger, etc) - root: HTMLElement -} - let keyToUtil = {'Select': SelectTester, 'Table': TableTester, 'Menu': MenuTester, 'ComboBox': ComboBoxTester, 'GridList': GridListTester} as const; export type PatternNames = keyof typeof keyToUtil; // Conditional type: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html -type ObjectType = - T extends 'Select' ? SelectTester : - T extends 'Table' ? TableTester : - T extends 'Menu' ? MenuTester : - T extends 'ComboBox' ? ComboBoxTester : - T extends 'GridList' ? GridListTester : - never; +type Tester = + T extends 'ComboBox' ? ComboBoxTester : + T extends 'GridList' ? GridListTester : + T extends 'Menu' ? MenuTester : + T extends 'Select' ? SelectTester : + T extends 'Table' ? TableTester : + never; -type ObjectOptionsTypes = - T extends 'Select' ? SelectOptions : - T extends 'Table' ? TableOptions : - T extends 'Menu' ? MenuOptions : - T extends 'ComboBox' ? ComboBoxOptions : - T extends 'GridList' ? GridListOptions : +type TesterOpts = + T extends 'ComboBox' ? ComboBoxTesterOpts : + T extends 'GridList' ? GridListTesterOpts : + T extends 'Menu' ? MenuTesterOpts : + T extends 'Select' ? SelectTesterOpts : + T extends 'Table' ? TableTesterOpts : never; let defaultAdvanceTimer = async (waitTime: number | undefined) => await new Promise((resolve) => setTimeout(resolve, waitTime)); export class User { - user; + private user; + /** + * The interaction type (mouse, touch, keyboard) that the test util user will use when interacting with a component. This can be overridden + * at the aria pattern util level if needed. + * @default mouse + */ interactionType: UserOpts['interactionType']; + /** + * A function used by the test utils to advance timers during interactions. Required for certain aria patterns (e.g. table). + */ advanceTimer: UserOpts['advanceTimer']; constructor(opts: UserOpts = {}) { @@ -67,7 +61,10 @@ export class User { this.advanceTimer = advanceTimer || defaultAdvanceTimer; } - createTester(patternName: T, opts: ObjectOptionsTypes): ObjectType { - return new (keyToUtil)[patternName]({...opts, user: this.user, interactionType: this.interactionType, advanceTimer: this.advanceTimer}) as ObjectType; + /** + * Creates an aria pattern tester, inheriting the options provided to the original user. + */ + createTester(patternName: T, opts: TesterOpts): Tester { + return new (keyToUtil)[patternName]({interactionType: this.interactionType, advanceTimer: this.advanceTimer, ...opts, user: this.user}) as Tester; } } diff --git a/packages/@react-spectrum/combobox/docs/ComboBox.mdx b/packages/@react-spectrum/combobox/docs/ComboBox.mdx index 9b2f1f9e5e0..b8d96bbaf46 100644 --- a/packages/@react-spectrum/combobox/docs/ComboBox.mdx +++ b/packages/@react-spectrum/combobox/docs/ComboBox.mdx @@ -11,8 +11,9 @@ import {Layout} from '@react-spectrum/docs'; export default Layout; import docs from 'docs:@react-spectrum/combobox'; +import comboboxUtils from 'docs:@react-aria/test-utils/src/combobox.ts'; import packageData from '@react-spectrum/combobox/package.json'; -import {HeaderInfo, PropTable, PageDescription} from '@react-spectrum/docs'; +import {HeaderInfo, PropTable, PageDescription, ClassAPI} from '@react-spectrum/docs'; ```jsx import import Add from '@spectrum-icons/workflow/Add'; @@ -992,3 +993,34 @@ behaviors in your test suite. Please also refer to [React Spectrum's test suite](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/combobox/test/ComboBox.test.js) if you find that the above isn't sufficient when resolving issues in your own test cases. + +`@react-aria/test-utils` also offers common combobox interaction utilities which you may find helpful when writing tests. See [here](../react-aria/testing.html#react-aria-test-utils) for more information on how to setup these utilities +in your tests. Below is the full definition of the combobox tester and a sample of how you could use it in your test suite. + + + +```ts +// Combobox.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse'}); +// ... + +it('ComboBox can select an option via keyboard', async function () { + // Render your test component/app and initialize the combobox tester + let {getByTestId} = render( + + ... + + ); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: getByTestId('test-combobox'), interactionType: 'keyboard'}); + + await comboboxTester.open(); + expect(comboboxTester.listbox).toBeTruthy(); + + let options = comboboxTester.options(); + await comboboxTester.selectOption({option: options[0]}); + expect(comboboxTester.combobox.value).toBe('One'); + expect(comboboxTester.listbox).toBeFalsy(); +}); +``` diff --git a/packages/@react-spectrum/list/docs/ListView.mdx b/packages/@react-spectrum/list/docs/ListView.mdx index 362fa71631f..2e6723f787c 100644 --- a/packages/@react-spectrum/list/docs/ListView.mdx +++ b/packages/@react-spectrum/list/docs/ListView.mdx @@ -14,7 +14,8 @@ import Anatomy from './anatomy.svg'; import ChevronRight from '@spectrum-icons/workflow/ChevronRight'; import docs from 'docs:@react-spectrum/list'; import dndDocs from 'docs:@react-spectrum/dnd'; -import {HeaderInfo, PropTable, PageDescription, TypeLink} from '@react-spectrum/docs'; +import gridlistUtil from 'docs:@react-aria/test-utils/src/gridlist.ts'; +import {HeaderInfo, PropTable, PageDescription, TypeLink, ClassAPI} from '@react-spectrum/docs'; import {Keyboard} from '@react-spectrum/text'; import packageData from '@react-spectrum/list/package.json'; @@ -1191,3 +1192,41 @@ behaviors in your test suite. Please also refer to [React Spectrum's test suite](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/list/test/ListView.test.js) if you find that the above isn't sufficient when resolving issues in your own test cases. + +`@react-aria/test-utils` also offers common gridlist interaction utilities which you may find helpful when writing tests. See [here](../react-aria/testing.html#react-aria-test-utils) for more information on how to setup these utilities +in your tests. Below is the full definition of the gridlist tester and a sample of how you could use it in your test suite. + + + +```ts +// ListView.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse'}); +// ... + +it('ListView can select a row via keyboard', async function () { + // Render your test component/app and initialize the gridlist tester + let {getByTestId} = render( + + ... + + ); + let gridlistTester = testUtilUser.createTester('GridList', {root: getByTestId('test-gridlist'), interactionType: 'keyboard'}); + + let row = gridListTester.rows[0]; + expect(row).not.toHaveClass('selected'); + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(0); + + await gridListTester.toggleRowSelection({index: 0}); + expect(row).toHaveClass('selected'); + expect(within(row).getByRole('checkbox')).toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(1); + + await gridListTester.toggleRowSelection({index: 0}); + expect(row).not.toHaveClass('selected'); + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(0); +}); +``` diff --git a/packages/@react-spectrum/menu/docs/MenuTrigger.mdx b/packages/@react-spectrum/menu/docs/MenuTrigger.mdx index ffcaade3523..3efbe16f862 100644 --- a/packages/@react-spectrum/menu/docs/MenuTrigger.mdx +++ b/packages/@react-spectrum/menu/docs/MenuTrigger.mdx @@ -11,7 +11,8 @@ import {Layout} from '@react-spectrum/docs'; export default Layout; import docs from 'docs:@react-spectrum/menu'; -import {HeaderInfo, PropTable, PageDescription} from '@react-spectrum/docs'; +import menuUtil from 'docs:@react-aria/test-utils/src/menu.ts'; +import {HeaderInfo, PropTable, PageDescription, ClassAPI} from '@react-spectrum/docs'; import packageData from '@react-spectrum/menu/package.json'; import {Keyboard} from '@react-spectrum/text'; @@ -256,3 +257,38 @@ behaviors in your test suite. Please also refer to [React Spectrum's test suite](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/menu/test/MenuTrigger.test.js) if you find that the above isn't sufficient when resolving issues in your own test cases. + +`@react-aria/test-utils` also offers common menu interaction utilities which you may find helpful when writing tests. See [here](../react-aria/testing.html#react-aria-test-utils) for more information on how to setup these utilities +in your tests. Below is the full definition of the menu tester and a sample of how you could use it in your test suite. + + + +```ts +// Menu.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse'}); +// ... + +it('Menu can open its submenu via keyboard', async function () { + // Render your test component/app and initialize the menu tester + let {getByTestId} = render( + + ... + + ); + let menuTester = testUtilUser.createTester('Menu', {root: getByTestId('test-menutrigger'), interactionType: 'keyboard'}); + + await menuTester.open(); + expect(menuTester.menu).toBeInTheDocument(); + let submenuTriggers = menuTester.submenuTriggers; + expect(submenuTriggers).toHaveLength(1); + + let submenuTester = await menuTester.openSubmenu({submenuTriggerText: 'Share…'}); + expect(submenuTester.menu).toBeInTheDocument(); + + await submenuTester.selectOption({option: submenuUtil.options()[0]}); + expect(submenuTester.menu).toBeInTheDocument(); + expect(menuTester.menu).toBeInTheDocument(); +}); +``` diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index 83d36783f07..24084233bb7 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -281,7 +281,7 @@ describe('MenuTrigger', function () { await menuTester.open(); let menu = menuTester.menu; expect(menu).toBeTruthy(); - let menuItems = menuTester.options; + let menuItems = menuTester.options(); let selectedItem = menuItems[1]; expect(selectedItem).toBe(document.activeElement); await menuTester.close(); @@ -292,7 +292,7 @@ describe('MenuTrigger', function () { menuTester.setInteractionType('keyboard'); fireEvent.keyDown(button, {key: 'ArrowDown', code: 40, charCode: 40}); fireEvent.keyUp(button, {key: 'ArrowDown', code: 40, charCode: 40}); - menuItems = menuTester.options; + menuItems = menuTester.options(); selectedItem = menuItems[1]; expect(selectedItem).toBe(document.activeElement); await menuTester.close(); @@ -300,7 +300,7 @@ describe('MenuTrigger', function () { // Opening menu via up arrow still autofocuses the selected item fireEvent.keyDown(button, {key: 'ArrowUp', code: 38, charCode: 38}); - menuItems = menuTester.options; + menuItems = menuTester.options(); selectedItem = menuItems[1]; expect(selectedItem).toBe(document.activeElement); }); @@ -355,17 +355,17 @@ describe('MenuTrigger', function () { let menuTester = testUtilUser.createTester('Menu', {root: tree.container}); fireEvent.keyDown(menuTester.trigger, {key: 'ArrowDown', code: 40, charCode: 40}); - let selectedItem = menuTester.options[0]; + let selectedItem = menuTester.options()[0]; expect(selectedItem).toBe(document.activeElement); fireEvent.keyDown(menuTester.menu, {key: 'ArrowDown', code: 40, charCode: 40}); - expect(menuTester.options[1]).toBe(document.activeElement); + expect(menuTester.options()[1]).toBe(document.activeElement); fireEvent.keyDown(menuTester.menu, {key: 'ArrowDown', code: 40, charCode: 40}); - expect(menuTester.options[2]).toBe(document.activeElement); + expect(menuTester.options()[2]).toBe(document.activeElement); fireEvent.keyDown(menuTester.menu, {key: 'ArrowUp', code: 38, charCode: 38}); - expect(menuTester.options[1]).toBe(document.activeElement); + expect(menuTester.options()[1]).toBe(document.activeElement); }); }); @@ -381,7 +381,7 @@ describe('MenuTrigger', function () { async function openAndTriggerMenuItem(tree, role, selectionMode, triggerEvent) { let menuTester = testUtilUser.createTester('Menu', {root: tree.container}); await menuTester.open(); - let menuItems = menuTester.options; + let menuItems = menuTester.options(); let itemToAction = menuItems[1]; await triggerEvent(itemToAction); act(() => {jest.runAllTimers();}); // FocusScope useLayoutEffect cleanup @@ -414,7 +414,7 @@ describe('MenuTrigger', function () { expect(onSelectionChange).not.toHaveBeenCalled(); let menu = menuTester.menu; - expect(menuTester.options[0]).toHaveAttribute('aria-checked', 'true'); + expect(menuTester.options()[0]).toHaveAttribute('aria-checked', 'true'); fireEvent.keyDown(menu, {key: 'Escape', code: 27, charCode: 27}); act(() => {jest.runAllTimers();}); // FocusScope useLayoutEffect cleanup act(() => {jest.runAllTimers();}); // FocusScope raf @@ -427,7 +427,7 @@ describe('MenuTrigger', function () { expect(onSelectionChange).not.toHaveBeenCalled(); menu = menuTester.menu; - expect(menuTester.options[0]).toHaveAttribute('aria-checked', 'true'); + expect(menuTester.options()[0]).toHaveAttribute('aria-checked', 'true'); expect(menu).toBeTruthy(); fireEvent.keyDown(menu, {key: 'Escape', code: 27, charCode: 27}); expect(onSelectionChange).not.toHaveBeenCalled(); diff --git a/packages/@react-spectrum/picker/docs/Picker.mdx b/packages/@react-spectrum/picker/docs/Picker.mdx index ba1ef556db1..f0faf73afe9 100644 --- a/packages/@react-spectrum/picker/docs/Picker.mdx +++ b/packages/@react-spectrum/picker/docs/Picker.mdx @@ -11,7 +11,8 @@ import {Layout} from '@react-spectrum/docs'; export default Layout; import docs from 'docs:@react-spectrum/picker'; -import {HeaderInfo, PropTable, PageDescription} from '@react-spectrum/docs'; +import selectUtil from 'docs:@react-aria/test-utils/src/select.ts'; +import {HeaderInfo, PropTable, PageDescription, ClassAPI} from '@react-spectrum/docs'; import packageData from '@react-spectrum/picker/package.json'; ```jsx import @@ -588,3 +589,32 @@ for more information on how to handle these behaviors in your test suite. Please also refer to [React Spectrum's test suite](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/picker/test/Picker.test.js) if you find that the above isn't sufficient when resolving issues in your own test cases. + +`@react-aria/test-utils` also offers common select interaction utilities which you may find helpful when writing tests. See [here](../react-aria/testing.html#react-aria-test-utils) for more information on how to setup these utilities +in your tests. Below is the full definition of the select tester and a sample of how you could use it in your test suite. + + + +```ts +// Picker.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse'}); +// ... + +it('Picker can select an option via keyboard', async function () { + // Render your test component/app and initialize the select tester + let {getByTestId} = render( + + ... + + ); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('test-select'), interactionType: 'keyboard'}); + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Select an item'); + expect(trigger).not.toHaveAttribute('data-pressed'); + + await selectTester.selectOption({optionText: 'Cat'}); + expect(trigger).toHaveTextContent('Cat'); +}); +``` diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 9b04a3b5d63..ad348d0c1d7 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -103,7 +103,7 @@ describe('Picker', function () { expect(picker).toHaveAttribute('aria-expanded', 'true'); expect(picker).toHaveAttribute('aria-controls', listbox.id); - let items = selectTester.options; + let items = selectTester.options(); expect(items.length).toBe(3); expect(items[0]).toHaveTextContent('One'); expect(items[1]).toHaveTextContent('Two'); @@ -214,7 +214,7 @@ describe('Picker', function () { expect(picker).toHaveAttribute('aria-expanded', 'true'); expect(picker).toHaveAttribute('aria-controls', listbox.id); - let items = selectTester.options; + let items = selectTester.options(); expect(items.length).toBe(3); expect(items[0]).toHaveTextContent('One'); expect(items[1]).toHaveTextContent('Two'); @@ -252,7 +252,7 @@ describe('Picker', function () { expect(picker).toHaveAttribute('aria-expanded', 'true'); expect(picker).toHaveAttribute('aria-controls', listbox.id); - let items = selectTester.options; + let items = selectTester.options(); expect(items.length).toBe(3); expect(items[0]).toHaveTextContent('One'); expect(items[1]).toHaveTextContent('Two'); @@ -982,7 +982,7 @@ describe('Picker', function () { await selectTester.open(); let listbox = selectTester.listbox; - let items = selectTester.options; + let items = selectTester.options(); expect(items.length).toBe(3); expect(items[0]).toHaveTextContent('One'); expect(items[1]).toHaveTextContent('Two'); @@ -1014,7 +1014,7 @@ describe('Picker', function () { await selectTester.open(); let listbox = selectTester.listbox; - let items = selectTester.options; + let items = selectTester.options(); expect(items.length).toBe(3); expect(items[0]).toHaveTextContent('Empty'); expect(items[1]).toHaveTextContent('Zero'); @@ -1105,7 +1105,7 @@ describe('Picker', function () { expect(picker).toHaveTextContent('Select…'); await selectTester.open(); - let items = selectTester.options; + let items = selectTester.options(); expect(items.length).toBe(3); expect(items[0]).toHaveTextContent('One'); expect(items[1]).toHaveTextContent('Two'); @@ -1187,7 +1187,7 @@ describe('Picker', function () { expect(listbox).toBeVisible(); expect(listbox).toHaveAttribute('aria-labelledby', label.id); - let items = selectTester.options; + let items = selectTester.options(); expect(items[0]).toHaveTextContent('One'); expect(items[1]).toHaveTextContent('Two'); expect(items[2]).toHaveTextContent('Three'); @@ -1385,7 +1385,7 @@ describe('Picker', function () { await selectTester.open(); let listbox = selectTester.listbox; - let items = selectTester.options; + let items = selectTester.options(); expect(items.length).toBe(6); let groups = selectTester.sections; @@ -1437,7 +1437,7 @@ describe('Picker', function () { await selectTester.open(); listbox = selectTester.listbox; - items = selectTester.options; + items = selectTester.options(); expect(items.length).toBe(6); expect(document.activeElement).toBe(items[1]); @@ -1546,7 +1546,7 @@ describe('Picker', function () { expect(picker).toHaveTextContent('Two'); await selectTester.open(); - let items = selectTester.options; + let items = selectTester.options(); expect(document.activeElement).toBe(items[1]); await selectTester.selectOption({optionText: 'Two'}); diff --git a/packages/@react-spectrum/table/docs/TableView.mdx b/packages/@react-spectrum/table/docs/TableView.mdx index 1ba899b6d23..18d04c7bcc1 100644 --- a/packages/@react-spectrum/table/docs/TableView.mdx +++ b/packages/@react-spectrum/table/docs/TableView.mdx @@ -12,8 +12,9 @@ export default Layout; import docs from 'docs:@react-spectrum/table'; import dndDocs from 'docs:@react-spectrum/dnd'; +import tableUtil from 'docs:@react-aria/test-utils/src/table.ts'; import tableTypes from 'docs:@react-types/table/src/index.d.ts'; -import {HeaderInfo, PropTable, PageDescription, TypeLink, VersionBadge} from '@react-spectrum/docs'; +import {HeaderInfo, PropTable, PageDescription, TypeLink, VersionBadge, ClassAPI} from '@react-spectrum/docs'; import {Keyboard} from '@react-spectrum/text'; import packageData from '@react-spectrum/table/package.json'; import ChevronRight from '@spectrum-icons/workflow/ChevronRight'; @@ -1957,3 +1958,42 @@ behaviors in your test suite. Please also refer to [React Spectrum's test suite](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-spectrum/table/test/Table.test.js) if you find that the above isn't sufficient when resolving issues in your own test cases. + +`@react-aria/test-utils` also offers common table interaction utilities which you may find helpful when writing tests. See [here](../react-aria/testing.html#react-aria-test-utils) for more information on how to setup these utilities +in your tests. Below is the full definition of the table tester and a sample of how you could use it in your test suite. + + + +```ts +// TableView.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse', advanceTimer: jest.advanceTimersByTime}); +// ... + +it('TableView can toggle row selection', async function () { + // Render your test component/app and initialize the table tester + let {getByTestId} = render( + + ... + + ); + let tableTester = testUtilUser.createTester('Table', {root: getByTestId('test-table')}); + expect(tableTester.selectedRows).toHaveLength(0); + + await tableTester.toggleSelectAll(); + expect(tableTester.selectedRows).toHaveLength(10); + + await tableTester.toggleRowSelection({index: 2}); + expect(tableTester.selectedRows).toHaveLength(9); + let checkbox = within(tableTester.rows[2]).getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + + await tableTester.toggleSelectAll(); + expect(tableTester.selectedRows).toHaveLength(10); + expect(checkbox).toBeChecked(); + + await tableTester.toggleSelectAll(); + expect(tableTester.selectedRows).toHaveLength(0); +}); +``` diff --git a/packages/dev/docs/pages/react-aria/testing.mdx b/packages/dev/docs/pages/react-aria/testing.mdx new file mode 100644 index 00000000000..1d2e6f2cdce --- /dev/null +++ b/packages/dev/docs/pages/react-aria/testing.mdx @@ -0,0 +1,103 @@ +{/* Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. */} + +import {Layout} from '@react-spectrum/docs'; +export default Layout; +import testUtilDocs from 'docs:@react-aria/test-utils'; +import combobox from 'docs:@react-aria/test-utils/src/combobox.ts'; +import gridlist from 'docs:@react-aria/test-utils/src/gridlist.ts'; +import menu from 'docs:@react-aria/test-utils/src/menu.ts'; +import select from 'docs:@react-aria/test-utils/src/select.ts'; +import table from 'docs:@react-aria/test-utils/src/table.ts'; +import {ClassAPI, FunctionAPI, InterfaceType, TypeContext, TypeLink} from '@react-spectrum/docs'; + +--- +category: Concepts +--- + +# Testing + +This page describes how to test an application built with React Aria. It documents the available testing utilities available +for each aria pattern and how they can be used to simulate common user interactions. + +## React Aria test utils + +### Introduction + +As both the adoption of our component libraries and the complexity of the components offered has grown, various testing pain points have surfaced, both from within the maintaining team and from consumers of the library itself. +The test writer may not be familiar with the internal structure of the component they are testing against and thus are unable to easily target/interact with the desired element within the component. Alternatively, the specifics +of what events to simulate for various interaction modes can be onerous to figure out and adds unnecessary friction for new adopters. + +To address this, we've created [@react-aria/test-utils](https://www.npmjs.com/package/@react-aria/test-utils) which features a set of testing utilities that aims to make writing unit tests easier for consumers of our component libraries +or for users who have built their own components following the respective ARIA pattern specification. By using the ARIA specification for any given component pattern as a source of truth, +we can make assumptions about the existence of specific aria attributes that allow us to navigate the component's DOM structure. Similarly, we can also expect that the component +permits specific interaction patterns described by the ARIA pattern specification and thus accurately simulate those interactions, using the aforementioned aria attributes to target the proper node +within the component or to verify that the component's state has changed appropriately post-interaction. By providing utilities to simulate these standard interaction and getters that +allow the user to easily look up the subcomponents of the component itself, we hope to simplify the overall test writing experience, leading towards easier adoption. + +These test utilities were inspired by various issues and observations that the maintainers of this library and consumers have experienced when writing tests against our components over the years. It is still very much +a work in progress so if you discover any issues or have any feedback please feel free to report them via [GitHub issues](https://github.com/adobe/react-spectrum/issues)! If you have implemented +any testing utilities yourself that you feel would be a good fit, we would be happy to field any pull request! Please read our [contributing guide](contribute.html) +for more information. + +### Installation + +`@react-aria/test-utils` can be installed using a package manager like [npm](https://docs.npmjs.com/cli/npm) or [yarn](https://classic.yarnpkg.com/lang/en/). + +``` +yarn add --dev @react-aria/test-utils +``` + +Please note that this library uses [@testing-library/react@15](https://www.npmjs.com/package/@testing-library/react) and [@testing-library/user-event](https://www.npmjs.com/package/@testing-library/user-event/v/13.1.5). This means that you need +to be on React 18+ in order for these utilities to work. + +### Setup + +Once installed, you can access the `User` that `@react-aria/test-utils` provides in your test file as shown below. This user only needs to be initialized once and accepts two options: `interactionType` and `advanceTimer`. `interactionType` will +initialize what mode of interaction (mouse, keyboard, or touch) will be used by default. This can be overridden at the pattern tester or interaction execution level if required. `advanceTimer` accepts a function that when called should advance timers (real or fake) +in the test by a given amount. This is required for certain interactions (e.g. long press) that some of the patterns support. + +Once the `User` is initialized, you can use its `createTester` method to initialize a specific ARIA pattern tester in your test cases. This gives you access to that pattern's specific utilities that you can then call +within your test to query for specific subcomponents or simulate common interactions. `createTester` requires two arguments, the first being the name of the ARIA pattern tester you are creating and the second being a set of initialization options specific to that +pattern, typically including the `root` element (e.g. the menu trigger button, table, etc). See [below](#patterns) for more details on what is supported for each individual ARIA pattern tester. + +```ts +// YourTest.test.ts +import {User} from '@react-aria/test-utils'; + +// Provide whatever method of advancing timers you use in your test, this example assumes Jest with fake timers +let testUtilUser = new User({interactionType: 'mouse', advanceTimer: jest.advanceTimersByTime}); +// ... + +it('my test case', async function () { + // Render your test component/app and initialize the table tester + render(); + let table = testUtilUser.createTester('Table', {root: screen.getByTestId('test_table')}); + // ... +}); +``` + +See below for the full definition of the `User` object. + + + +### Patterns + +Below is a list of the ARIA patterns testers currently supported by `createTester`. See the accompanying component testing docs pages for a sample of how to use +the testers in your test suite. + +- [React Aria Components ComboBox](ComboBox.html#testing) and [React Spectrum ComboBox](../react-spectrum/ComboBox.html#testing) + +- [React Aria Components GridList](GridList.html#testing) and [React Spectrum ListView](../react-spectrum/ListView.html#testing) + +- [React Aria Components Menu](Menu.html#testing) and [React Spectrum MenuTrigger](../react-spectrum/MenuTrigger.html#testing) + +- [React Aria Components Select](Select.html#testing) and [React Spectrum Picker](../react-spectrum/Picker.html#testing) + +- [React Aria Components Table](Table.html#testing) and [React Spectrum TableView](../react-spectrum/TableView.html#testing) diff --git a/packages/dev/docs/pages/react-spectrum/testing.mdx b/packages/dev/docs/pages/react-spectrum/testing.mdx index d52eddd236c..04fc213564d 100644 --- a/packages/dev/docs/pages/react-spectrum/testing.mdx +++ b/packages/dev/docs/pages/react-spectrum/testing.mdx @@ -18,7 +18,7 @@ category: Concepts # Testing -This page describes how to test an application built with with React Spectrum, including how to +This page describes how to test an application built with React Spectrum, including how to query the DOM tree for elements and simulate user interactions. ## Introduction @@ -337,6 +337,12 @@ fireEvent.mouseMove(thumb, {pageX: 50}); fireEvent.mouseUp(thumb, {pageX: 50}); ``` +### Test Utilities + +In addition to some of the test utilities mentioned above, `@react-spectrum/test-utils` re-exports the same test utils available in `@react-aria/test-utils`, including +the ARIA pattern testers documented [here](../react-aria/testing.html#react-aria-test-utils). Those testers can be used with React Spectrum components as well and can be combined with the generalized +testing advice above. + ## Snapshot tests If you are using React 16 or 17, you may run into an issue where the ids generated by the React Spectrum components are changing on every snapshot. To remedy this, simply wrap your component in a [SSRProvider](../react-aria/SSRProvider.html). diff --git a/packages/react-aria-components/docs/ComboBox.mdx b/packages/react-aria-components/docs/ComboBox.mdx index 6aed3b95463..1a4ceb0ec90 100644 --- a/packages/react-aria-components/docs/ComboBox.mdx +++ b/packages/react-aria-components/docs/ComboBox.mdx @@ -12,7 +12,8 @@ export default Layout; import docs from 'docs:react-aria-components'; import statelyDocs from 'docs:@react-stately/combobox'; -import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable} from '@react-spectrum/docs'; +import comboboxUtils from 'docs:@react-aria/test-utils/src/combobox.ts'; +import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, ClassAPI} from '@react-spectrum/docs'; import styles from '@react-spectrum/docs/src/docs.css'; import packageData from 'react-aria-components/package.json'; import Anatomy from './ComboBoxAnatomy.svg'; @@ -1472,3 +1473,36 @@ function ComboBoxClearButton() { ### Hooks If you need to customize things even further, such as accessing internal state, intercepting events, or customizing the DOM structure, you can drop down to the lower level Hook-based API. See [useComboBox](useComboBox.html) for more details. + +## Testing + +`@react-aria/test-utils` also offers common combobox interaction utilities which you may find helpful when writing tests. See [here](./testing.html#react-aria-test-utils) for more information on how to setup these utilities +in your tests. Below is the full definition of the combobox tester and a sample of how you could use it in your test suite. + + + +```ts +// Combobox.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse'}); +// ... + +it('ComboBox can select an option via keyboard', async function () { + // Render your test component/app and initialize the combobox tester + let {getByTestId} = render( + + ... + + ); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: getByTestId('test-combobox'), interactionType: 'keyboard'}); + + await comboboxTester.open(); + expect(comboboxTester.listbox).toBeTruthy(); + + let options = comboboxTester.options(); + await comboboxTester.selectOption({option: options[0]}); + expect(comboboxTester.combobox.value).toBe('One'); + expect(comboboxTester.listbox).toBeFalsy(); +}); +``` diff --git a/packages/react-aria-components/docs/GridList.mdx b/packages/react-aria-components/docs/GridList.mdx index 6022c6ea103..ca8fcc831d5 100644 --- a/packages/react-aria-components/docs/GridList.mdx +++ b/packages/react-aria-components/docs/GridList.mdx @@ -12,7 +12,8 @@ export default Layout; import docs from 'docs:react-aria-components'; import sharedDocs from 'docs:@react-types/shared'; -import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable} from '@react-spectrum/docs'; +import gridlistUtil from 'docs:@react-aria/test-utils/src/gridlist.ts'; +import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, ClassAPI} from '@react-spectrum/docs'; import styles from '@react-spectrum/docs/src/docs.css'; import packageData from 'react-aria-components/package.json'; import Anatomy from './GridListAnatomy.svg'; @@ -578,7 +579,7 @@ Note that you are responsible for the styling of disabled rows, however, the sel When `disabledBehavior` is set to `selection`, interactions such as focus, dragging, or actions can still be performed on disabled rows. ```tsx example - + +```ts +// GridList.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse'}); +// ... + +it('GridList can select a row via keyboard', async function () { + // Render your test component/app and initialize the gridlist tester + let {getByTestId} = render( + + ... + + ); + let gridlistTester = testUtilUser.createTester('GridList', {root: getByTestId('test-gridlist'), interactionType: 'keyboard'}); + + let row = gridListTester.rows[0]; + expect(row).not.toHaveClass('selected'); + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(0); + + await gridListTester.toggleRowSelection({index: 0}); + expect(row).toHaveClass('selected'); + expect(within(row).getByRole('checkbox')).toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(1); + + await gridListTester.toggleRowSelection({index: 0}); + expect(row).not.toHaveClass('selected'); + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(0); +}); +``` diff --git a/packages/react-aria-components/docs/Menu.mdx b/packages/react-aria-components/docs/Menu.mdx index 4cc80314895..117c90b8723 100644 --- a/packages/react-aria-components/docs/Menu.mdx +++ b/packages/react-aria-components/docs/Menu.mdx @@ -11,7 +11,8 @@ import {Layout} from '@react-spectrum/docs'; export default Layout; import docs from 'docs:react-aria-components'; -import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable} from '@react-spectrum/docs'; +import menuUtil from 'docs:@react-aria/test-utils/src/menu.ts'; +import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, ClassAPI} from '@react-spectrum/docs'; import styles from '@react-spectrum/docs/src/docs.css'; import packageData from 'react-aria-components/package.json'; import Anatomy from './MenuAnatomy.svg'; @@ -681,7 +682,7 @@ function Example() { ]; return ( - ``` + +## Testing + +`@react-aria/test-utils` also offers common menu interaction utilities which you may find helpful when writing tests. See [here](./testing.html#react-aria-test-utils) for more information on how to setup these utilities +in your tests. Below is the full definition of the menu tester and a sample of how you could use it in your test suite. + + + +```ts +// Menu.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse'}); +// ... + +it('Menu can open its submenu via keyboard', async function () { + // Render your test component/app and initialize the menu tester + let {getByTestId} = render( + + ... + + ); + let menuTester = testUtilUser.createTester('Menu', {root: getByTestId('test-menutrigger'), interactionType: 'keyboard'}); + + await menuTester.open(); + expect(menuTester.menu).toBeInTheDocument(); + let submenuTriggers = menuTester.submenuTriggers; + expect(submenuTriggers).toHaveLength(1); + + let submenuTester = await menuTester.openSubmenu({submenuTriggerText: 'Share…'}); + expect(submenuTester.menu).toBeInTheDocument(); + + await submenuTester.selectOption({option: submenuUtil.options()[0]}); + expect(submenuTester.menu).toBeInTheDocument(); + expect(menuTester.menu).toBeInTheDocument(); +}); +``` diff --git a/packages/react-aria-components/docs/Select.mdx b/packages/react-aria-components/docs/Select.mdx index 6b3a3c3c851..36d8f6438f0 100644 --- a/packages/react-aria-components/docs/Select.mdx +++ b/packages/react-aria-components/docs/Select.mdx @@ -12,7 +12,8 @@ export default Layout; import docs from 'docs:react-aria-components'; import statelyDocs from 'docs:@react-stately/select'; -import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable} from '@react-spectrum/docs'; +import selectUtil from 'docs:@react-aria/test-utils/src/select.ts'; +import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, ClassAPI} from '@react-spectrum/docs'; import styles from '@react-spectrum/docs/src/docs.css'; import packageData from 'react-aria-components/package.json'; import Anatomy from './SelectAnatomy.svg'; @@ -1237,3 +1238,33 @@ knowing that we manage the focus in this way and thus throw this false positive. To facilitate the suppression of this false positive, the `data-a11y-ignore="aria-hidden-focus"` data attribute is automatically applied to the problematic element and references the relevant `AXE` rule. Please use this data attribute to target the problematic element and exclude it from your automated accessibility tests as shown [here](./accessibility.html#false-positives). + +## Testing + +`@react-aria/test-utils` also offers common select interaction utilities which you may find helpful when writing tests. See [here](./testing.html#react-aria-test-utils) for more information on how to setup these utilities +in your tests. Below is the full definition of the select tester and a sample of how you could use it in your test suite. + + + +```ts +// Select.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse'}); +// ... + +it('Select can select an option via keyboard', async function () { + // Render your test component/app and initialize the select tester + let {getByTestId} = render( + + ); + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('test-select'), interactionType: 'keyboard'}); + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Select an item'); + + await selectTester.selectOption({optionText: 'Cat'}); + expect(trigger).toHaveTextContent('Cat'); +}); +``` diff --git a/packages/react-aria-components/docs/Table.mdx b/packages/react-aria-components/docs/Table.mdx index b0f1556c485..dde95b257c5 100644 --- a/packages/react-aria-components/docs/Table.mdx +++ b/packages/react-aria-components/docs/Table.mdx @@ -12,7 +12,8 @@ export default Layout; import docs from 'docs:react-aria-components'; import sharedDocs from 'docs:@react-types/shared'; -import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable} from '@react-spectrum/docs'; +import tableUtil from 'docs:@react-aria/test-utils/src/table.ts'; +import {PropTable, HeaderInfo, TypeLink, PageDescription, StateTable, ContextTable, ClassAPI} from '@react-spectrum/docs'; import styles from '@react-spectrum/docs/src/docs.css'; import packageData from 'react-aria-components/package.json'; import Anatomy from './TableAnatomy.svg'; @@ -724,7 +725,7 @@ Note that you are responsible for the styling of disabled rows, however, the sel By default, only row selection is disabled. When `disabledBehavior` is set to `all`, all interactions such as focus, dragging, and actions are also disabled. ```tsx example - + +```ts +// Table.test.ts +import {User} from '@react-aria/test-utils'; + +let testUtilUser = new User({interactionType: 'mouse', advanceTimer: jest.advanceTimersByTime}); +// ... + +it('Table can toggle row selection', async function () { + // Render your test component/app and initialize the table tester + let {getByTestId} = render( +
+ ... +
+ ); + let tableTester = testUtilUser.createTester('Table', {root: getByTestId('test-table')}); + expect(tableTester.selectedRows).toHaveLength(0); + + await tableTester.toggleSelectAll(); + expect(tableTester.selectedRows).toHaveLength(10); + + await tableTester.toggleRowSelection({index: 2}); + expect(tableTester.selectedRows).toHaveLength(9); + let checkbox = within(tableTester.rows[2]).getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + + await tableTester.toggleSelectAll(); + expect(tableTester.selectedRows).toHaveLength(10); + expect(checkbox).toBeChecked(); + + await tableTester.toggleSelectAll(); + expect(tableTester.selectedRows).toHaveLength(0); +}); +``` diff --git a/packages/react-aria-components/test/Menu.test.js b/packages/react-aria-components/test/Menu.test.js index d31add9c079..77b4df6df4e 100644 --- a/packages/react-aria-components/test/Menu.test.js +++ b/packages/react-aria-components/test/Menu.test.js @@ -1031,7 +1031,7 @@ describe('Menu', () => { let submenu = submenuUtil.menu; expect(submenu).toBeInTheDocument(); - let submenuItems = submenuUtil.options; + let submenuItems = submenuUtil.options(); expect(submenuItems).toHaveLength(6); let groupsInSubmenu = submenuUtil.sections; diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index 1d620f85f40..aee26a60a51 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -73,7 +73,7 @@ describe('Select', () => { expect(listbox.closest('.react-aria-Popover')).toBeInTheDocument(); expect(listbox.closest('.react-aria-Popover')).toHaveAttribute('data-trigger', 'Select'); - let options = selectTester.options; + let options = selectTester.options(); expect(options).toHaveLength(3); await user.click(options[1]); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 8acab554cb8..76d752cfbda 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -214,7 +214,7 @@ describe('Table', () => { expect(cell).toHaveAttribute('class', 'react-aria-Cell'); } - for (let cell of tableTester.cells) { + for (let cell of tableTester.cells()) { expect(cell).toHaveAttribute('class', 'react-aria-Cell'); } }); diff --git a/yarn.lock b/yarn.lock index e3234482525..1832f1e6516 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6365,7 +6365,6 @@ __metadata: peerDependencies: "@testing-library/react": ^15.0.7 "@testing-library/user-event": ^13.0.0 || ^14.0.0 - jest: ^29.5.0 react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 languageName: unknown linkType: soft