diff --git a/packages/@react-aria/test-utils/src/index.ts b/packages/@react-aria/test-utils/src/index.ts index b5b7da34492..ddad90b9a57 100644 --- a/packages/@react-aria/test-utils/src/index.ts +++ b/packages/@react-aria/test-utils/src/index.ts @@ -14,5 +14,15 @@ export {triggerLongPress} from './events'; export {installMouseEvent, installPointerEvent} from './testSetup'; export {pointerMap} from './userEventMaps'; export {User} from './user'; +// TODO: had to export these for the docs, but not sure why I didn't have to do +// so for the v3 docs? +export {ComboBoxTester} from './combobox'; +export {GridListTester} from './gridlist'; +export {ListBoxTester} from './listbox'; +export {MenuTester} from './menu'; +export {SelectTester} from './select'; +export {TableTester} from './table'; +export {TabsTester} from './tabs'; +export {TreeTester} from './tree'; export type {UserOpts} from './types'; diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index d1edbdb9d86..5c441ef03f4 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -1,3 +1,4 @@ +import {InstallCommand} from '../../src/InstallCommand'; import {Layout} from '../../src/Layout'; export default Layout; @@ -6,6 +7,7 @@ import vanillaDocs from 'docs:vanilla-starter/Table'; import '../../tailwind/tailwind.css'; import Anatomy from 'react-aria-components/docs/TableAnatomy.svg'; import {InlineAlert, Heading, Content} from '@react-spectrum/s2' +import testUtilDocs from 'docs:@react-aria/test-utils'; export const tags = ['data', 'grid']; @@ -90,7 +92,7 @@ export const tags = ['data', 'grid']; ## Content -`Table` follows the [Collection Components API](collections.html?component=Table), accepting both static and dynamic collections. +`Table` follows the [Collection Components API](collections.html?component=Table), accepting both static and dynamic collections. In this example, both the columns and the rows are provided to the table via a render function, enabling the user to hide and show columns and add additional rows. ```tsx render @@ -718,3 +720,88 @@ function ReorderableTable() { ### TableLoadMoreItem + + +## Testing + +TODO make this a subpage + + +### General setup + +Table features long press interactions on its rows depending on the row actions provided and if the user is interacting with the table on +a touch device. Please see the following sections in the general testing documentation for more information on how to handle these +behaviors in your test suite. + +[Timers](testing.html#timers) + +[Long press](testing.html#simulating-user-long-press) + +### Test utils + +`@react-aria/test-utils` offers common table interaction utilities which you may find helpful when writing tests. To install, simply +add it to your dev dependencies via your preferred package manager. + + + + + Requirements + Please note that this library uses [@testing-library/react@16](https://www.npmjs.com/package/@testing-library/react) and [@testing-library/user-event@14](https://www.npmjs.com/package/@testing-library/user-event). This means that you need to be on React 18+ in order for these utilities to work. + + +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 then can be used to generate +the `Table` tester in your test cases. This gives you access to `Table` specific utilities that you can then call within your test to query for specific subcomponents or simulate common interactions. +The example test case below shows how you might go about setting up the `Table` tester, use it to simulate row selection, and verify the table's state after each interaction. + +```ts +// Table.test.ts +import {render, within} from '@testing-library/react'; +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({row: 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); +}); +``` + +See below for the full definition of the `User` and the `Table` tester. + + + + +### Testing FAQ + +> When using the test utils, what if a certain interaction errors or doesn't seem to result in the expected state? + +In cases like this, first double check your test setup and make sure that your test is rendering your table in its expected +state before the test util interaction call. If everything looks correct, you can always fall back to simulating interactions manually, +and using the test util to query your table's state post interaction. + +> The tester doesn't offer a specific interaction flow, what should I do? + +Whenever the table tester queries its rows/cells/etc or triggers a user flow, it does so against the current state of the table. Therefore the table test can be used alongside +whatever simulated user flow you add. diff --git a/packages/dev/s2-docs/pages/react-aria/testing.mdx b/packages/dev/s2-docs/pages/react-aria/testing.mdx new file mode 100644 index 00000000000..803a9c23f76 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/testing.mdx @@ -0,0 +1,486 @@ +import {VersionBadge} from '../../src/VersionBadge'; +import {InstallCommand} from '../../src/InstallCommand'; +import {Layout} from '../../src/Layout'; +export default Layout; + +import testUtilDocs from 'docs:@react-aria/test-utils'; +import {InlineAlert, Heading, Content} from '@react-spectrum/s2' + +export const section = 'Guides'; +export const description = 'Writing tests for apps built with React Aria'; + +# 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. + +## Testing semantics + +The recommended way to query for React Aria Components and their internals is by semantics. React Aria +Components implement [ARIA patterns](https://www.w3.org/TR/wai-aria-practices-1.2/). ARIA is a W3C standard +that specifies the semantics for many UI components. Unlike the DOM structure of the component, these semantics are much less likely to change over time, +making them ideal to query for. + +The main attribute to look for when querying is the [role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques). +This attribute represents the type of element a DOM node represents, e.g. a button, list option, or tab. + +### React Testing Library + +[React Testing Library](https://testing-library.com/docs/react-testing-library/intro) is useful because it +enforces that you write tests using semantics instead of implementation details. We use React Testing Library +to test React Aria itself, and it's quite easy to [query](https://testing-library.com/docs/dom-testing-library/api-queries) +elements by role, text, label, etc. + +```tsx +import {render} from '@testing-library/react'; + +let tree = render(); +let option = tree.getByRole('button'); +``` + +## Test ids + +Querying by semantics covers many scenarios, but what if you have many buttons on a page or its text changes due to translations based on locale? +In these cases, you may need a way to identify specific elements in tests, and that's where test ids come in. + +React Aria Components pass all [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes) +through to their underlying DOM nodes, which allows you to use an attribute like `data-testid` to identify +a particular instance of a component. + +```tsx +import {render} from '@testing-library/react'; +import {Input, Label, TextField} from 'react-aria-components'; + +function LoginForm() { + return ( + <> + + + + + + + + + + ); +} + +let tree = render(); +let username = tree.getByTestId('username'); +let password = tree.getByTestId('password'); +``` + +## Triggering events + +React Aria Components rely on many different browser events to support different devices and platforms, so it's important to simulate +these correctly in your tests. For example, a click is really a `mousemove` and `mouseover` the target, followed +by `mousedown`, `focus`, and `mouseup` events, and finally a `click` event. + +The best way to handle this is with the [user-event](https://github.com/testing-library/user-event) library. +This lets you trigger high level interactions like a user would, and the library handles firing all of the individual +events that make up that interaction. + +```tsx +import {render} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +let tree = render(); + +// Click on the username field to focus it, and enter the value. +userEvent.click(tree.getByLabelText('Username')); +userEvent.type(document.activeElement, 'devon'); + +// Tab to the password field, and enter the value. +userEvent.tab(); +userEvent.type(document.activeElement, 'Pas$w0rd'); + +// Tab to the submit button and click it. +userEvent.tab(); +userEvent.click(document.activeElement); +``` + +## Test setup and common gotchas + +### Timers + +If you are using fake timers in your test suite, be aware that you may need to advance your timers after various interactions. We have `requestAnimationFrame` calls in various underlying hooks that you will need to also handle by advancing your timers in the tests. +This happens most prominently in our collection components after selection. In Jest, this can be handled by calling `act(() => jest.runAllTimers());` but you may require more precise control +depending on the other time-sensitive behavior you are testing. Please see [Jest's timer docs](https://jestjs.io/docs/timer-mocks) or the equivalent docs of your test frameworks for more information on how to do so. +It is also a good idea to run all timers to completion after each test case to avoid any left over transitions or timeouts that a component may have setup during its lifecycle. + +```tsx +afterEach(() => { + act(() => jest.runAllTimers()); +}); +``` + +Consider adding a `act(() => jest.runAllTimers());` after your simulated user interaction if you run into a test failure that looks like the following: + +``` +TestingLibraryElementError: Unable to find an accessible element with the role "listbox" +``` + +If you are using real timers instead, you can await a particular state of your app to be reached. If you are using React Testing Library, you can perform a `waitFor` query +to wait for a dialog to appear: + +```tsx +await waitFor(() => { + expect(getByRole('dialog')).toBeInTheDocument(); +}); +``` + +### Simulating user long press + +Some components like Menu support long press operations. Unfortunately, the approach of using the userEvent library to simulate a press event and running timers to hit the +long press internal timer threshold isn't sufficient due to `useLongPress`'s usage of `PointerEvent` and our own detection of `virtual` vs `mouse`/`touch` pointer types. Mock [PointerEvent](https://github.com/adobe/react-spectrum/blob/16ff0efac57eebeb1cd601ab376ce7c58a4e4efd/packages/dev/test-utils/src/events.ts#L70-L103) +globally and use `fireEvent` from `@testing-library/react` to properly simulate these long press events in your tests. +If you are using Jest, you can call our utility to automatically set up and tear down this mock in your test. +Additionally, if you are using fake timers and don't need to control the specific timings around the long press interaction, feel free to use our utility as shown below. + +```tsx +import {fireEvent} from '@testing-library/react'; +import {installPointerEvent, triggerLongPress} from '@react-aria/test-utils'; +installPointerEvent(); + +// In test case +let button = getByRole('button'); + +// With fireEvent and specific timing control +fireEvent.pointerDown(el, {pointerType: 'touch'}); +act(() => jest.advanceTimersByTime(800)); +fireEvent.up(el, {pointerType: 'touch'}); + +// With triggerLongPress +triggerLongPress(button); +``` + +### Simulating move event + +Components like ColorArea, ColorSlider, ColorWheel, and Slider each feature a draggable handle that a user can interact with to change the component's value. Similar to long press, the interactions offered by userEvent library aren't sufficient to trigger +the underlying event handlers governing these drag/move operations. [Mock MouseEvent globally](https://github.com/adobe/react-spectrum/blob/f40b575e38837e1aa7cabf0431406e81275d118a/packages/%40react-aria/test-utils/src/testSetup.ts#L16-L36) and `fireEvent` from `@testing-library/react` to simulate these drag/move events in your tests. +If you are using Jest, you can call our utility to automatically set up and tear down this mock in your test. Additionally, the track dimensions +for the draggable handle should be mocked so that the move operation calculations can be properly computed. + +```tsx +import {fireEvent} from '@testing-library/react'; +import {installMouseEvent} from '@react-aria/test-utils'; +installMouseEvent(); + +beforeAll(() => { + jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({top: 0, left: 0, width: 100, height: 10})); +}) + +// In test case +let sliderThumb = getByRole('slider').parentElement; + +// With fireEvent, move thumb from 0 to 50 +fireEvent.mouseDown(thumb, {clientX: 0, pageX: 0}); +fireEvent.mouseMove(thumb, {pageX: 50}); +fireEvent.mouseUp(thumb, {pageX: 50}); +``` + +## React Aria test utils + +TODO can't place this next to the header here + + +[@react-aria/test-utils](https://www.npmjs.com/package/@react-aria/test-utils) is a set of testing utilities that aims to make writing unit tests easier for consumers of React Aria +or for users who have built their own components following the respective ARIA pattern specification. + +### Installation + + + + + Requirements + Please note that this library uses [@testing-library/react@16](https://www.npmjs.com/package/@testing-library/react) and [@testing-library/user-event@14](https://www.npmjs.com/package/@testing-library/user-event). 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 then can be used to generate +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. +See [below](#patterns) for what patterns are currently supported. + +```ts +// YourTest.test.ts +import {screen} from '@testing-library/react'; +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. +// 'interactionType' specifies what mode of interaction should be simulated by the tester +// 'advanceTimer' is used by the tester to advance the timers in the tests for specific interactions (e.g. long press) +let testUtilUser = new User({interactionType: 'mouse', advanceTimer: jest.advanceTimersByTime}); +// ... + +it('my test case', async function () { + // Render your test component/app + render(); + // Initialize the table tester via providing the 'Table' pattern name and the root element of said table + let table = testUtilUser.createTester('Table', {root: screen.getByTestId('test_table')}); + + // ... +}); +``` + +See below for the full definition of the `User` object. + + + +### Patterns + + + +<> + ```ts isInSwitcher + // Combobox.test.ts + import {render} from '@testing-library/react'; + 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).toBeInTheDocument(); + + let options = comboboxTester.options(); + await comboboxTester.selectOption({option: options[0]}); + expect(comboboxTester.combobox.value).toBe('One'); + expect(comboboxTester.listbox).not.toBeInTheDocument(); + }); + ``` + + + +<> + ```ts isInSwitcher + // GridList.test.ts + import {render, within} from '@testing-library/react'; + 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(within(row).getByRole('checkbox')).not.toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(0); + + await gridListTester.toggleRowSelection({row: 0}); + expect(within(row).getByRole('checkbox')).toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(1); + + await gridListTester.toggleRowSelection({row: 0}); + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + expect(gridListTester.selectedRows).toHaveLength(0); + }); + ``` + + + +<> + ```ts isInSwitcher + // ListBox.test.ts + import {render} from '@testing-library/react'; + import {User} from '@react-aria/test-utils'; + + let testUtilUser = new User({interactionType: 'mouse'}); + // ... + + it('ListBox can select an option via keyboard', async function () { + // Render your test component/app and initialize the listbox tester + let {getByTestId} = render( + + ... + + ); + let listboxTester = testUtilUser.createTester('ListBox', {root: getByTestId('test-listbox'), interactionType: 'keyboard'}); + + await listboxTester.toggleOptionSelection({option: 4}); + expect(listboxTester.options()[4]).toHaveAttribute('aria-selected', 'true'); + }); + ``` + + + +<> + ```ts isInSwitcher + // Menu.test.ts + import {render} from '@testing-library/react'; + 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({submenuTrigger: 'Share…'}); + expect(submenuTester.menu).toBeInTheDocument(); + + await submenuTester.selectOption({option: submenuTester.options()[0]}); + expect(submenuTester.menu).not.toBeInTheDocument(); + expect(menuTester.menu).not.toBeInTheDocument(); + }); + ``` + + + +<> + ```ts isInSwitcher + // Select.test.ts + import {render} from '@testing-library/react'; + 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({option: 'Cat'}); + expect(trigger).toHaveTextContent('Cat'); + }); + ``` + + + +<> + ```ts isInSwitcher + // Table.test.ts + import {render, within} from '@testing-library/react'; + 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({row: 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); + }); + ``` + + + +<> + ```ts isInSwitcher + // Tabs.test.ts + import {render} from '@testing-library/react'; + import {User} from '@react-aria/test-utils'; + + let testUtilUser = new User({interactionType: 'mouse'}); + // ... + + it('Tabs can change selection via keyboard', async function () { + // Render your test component/app and initialize the listbox tester + let {getByTestId} = render( + + ... + + ); + let tabsTester = testUtilUser.createTester('Tabs', {root: getByTestId('test-tabs'), interactionType: 'keyboard'}); + + let tabs = tabsTester.tabs; + expect(tabsTester.selectedTab).toBe(tabs[0]); + + await tabsTester.triggerTab({tab: 1}); + expect(tabsTester.selectedTab).toBe(tabs[1]); + }); + ``` + + + +<> + ```ts isInSwitcher + // Tree.test.ts + import {render, within} from '@testing-library/react'; + import {User} from '@react-aria/test-utils'; + + let testUtilUser = new User({interactionType: 'mouse'}); + // ... + + it('Tree can select a item via keyboard', async function () { + // Render your test component/app and initialize the Tree tester + let {getByTestId} = render( + + ... + + ); + let treeTester = testUtilUser.createTester('Tree', {root: getByTestId('test-tree'), interactionType: 'keyboard'}); + + await treeTester.toggleRowSelection({row: 0}); + expect(treeTester.selectedRows).toHaveLength(1); + expect(within(treeTester.rows[0]).getByRole('checkbox')).toBeChecked(); + + await treeTester.toggleRowSelection({row: 1}); + expect(treeTester.selectedRows).toHaveLength(2); + expect(within(treeTester.rows[1]).getByRole('checkbox')).toBeChecked(); + + await treeTester.toggleRowSelection({row: 0}); + expect(treeTester.selectedRows).toHaveLength(1); + expect(within(treeTester.rows[0]).getByRole('checkbox')).not.toBeChecked(); + }); + ``` + + + +
diff --git a/packages/dev/s2-docs/pages/s2/testing.mdx b/packages/dev/s2-docs/pages/s2/testing.mdx new file mode 100644 index 00000000000..f56c8b4abd9 --- /dev/null +++ b/packages/dev/s2-docs/pages/s2/testing.mdx @@ -0,0 +1,421 @@ +import {VersionBadge} from '../../src/VersionBadge'; +import {InstallCommand} from '../../src/InstallCommand'; +import {Layout} from '../../src/Layout'; +export default Layout; + +import testUtilDocs from 'docs:@react-aria/test-utils'; +import {InlineAlert, Heading, Content} from '@react-spectrum/s2' + +export const section = 'Guides'; +export const description = 'Writing tests for apps built with React Spectrum'; + +# Testing + +This page describes how to test an application built with React Spectrum. It documents the available testing utilities available for each aria pattern and how they can be used to simulate common user interactions. + +## Testing semantics + +The recommended way to query for React Spectrum components and their internals is by semantics. React Spectrum +Components implement [ARIA patterns](https://www.w3.org/TR/wai-aria-practices-1.2/). ARIA is a W3C standard +that specifies the semantics for many UI components. Unlike the class names and DOM structure of the component, these +semantics are much less likely to change over time, making them ideal to query for. + +The main attribute to look for when querying is the [role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques). +This attribute represents the type of element a DOM node represents, e.g. a button, list option, or tab. + +### React Testing Library + +[React Testing Library](https://testing-library.com/docs/react-testing-library/intro) is useful because it +enforces that you write tests using semantics instead of implementation details. We use React Testing Library +to test React Spectrum itself, and it's quite easy to [query](https://testing-library.com/docs/dom-testing-library/api-queries) +elements by role, text, label, etc. + +```tsx +import {render} from '@testing-library/react'; + +let tree = render(); +let option = tree.getByRole('button'); +``` + +## Test ids + +Querying by semantics covers many scenarios, but what if you have many buttons on a page or its text changes due to translations based on locale? +In these cases, you may need a way to identify specific elements in tests, and that's where test ids come in. + +React Spectrum components pass all [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes) +through to their underlying DOM nodes, which allows you to use an attribute like `data-testid` to identify +a particular instance of a component. + +```tsx +import {render} from '@testing-library/react'; +import {TextField} from '@react-spectrum/s2'; + +function LoginForm() { + return ( + <> + + + + ); +} + +let tree = render(); +let username = tree.getByTestId('username'); +let password = tree.getByTestId('password'); +``` + +## Triggering events + +React Spectrum components rely on many different browser events to support different devices and platforms, so it's important to simulate +these correctly in your tests. For example, a click is really a `mousemove` and `mouseover` the target, followed +by `mousedown`, `focus`, and `mouseup` events, and finally a `click` event. + +The best way to handle this is with the [user-event](https://github.com/testing-library/user-event) library. +This lets you trigger high level interactions like a user would, and the library handles firing all of the individual +events that make up that interaction. + +```tsx +import {render} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +let tree = render(); + +// Click on the username field to focus it, and enter the value. +userEvent.click(tree.getByLabelText('Username')); +userEvent.type(document.activeElement, 'devon'); + +// Tab to the password field, and enter the value. +userEvent.tab(); +userEvent.type(document.activeElement, 'Pas$w0rd'); + +// Tab to the submit button and click it. +userEvent.tab(); +userEvent.click(document.activeElement); +``` + +## Test setup and common gotchas + +### Timers + +If you are using fake timers in your test suite, be aware that you may need to advance your timers after various interactions. We have `requestAnimationFrame` calls in various underlying hooks that you will need to also handle by advancing your timers in the tests. +This happens most prominently in our collection components after selection. In Jest, this can be handled by calling `act(() => jest.runAllTimers());` but you may require more precise control +depending on the other time-sensitive behavior you are testing. Please see [Jest's timer docs](https://jestjs.io/docs/timer-mocks) or the equivalent docs of your test frameworks for more information on how to do so. +It is also a good idea to run all timers to completion after each test case to avoid any left over transitions or timeouts that a component may have setup during its lifecycle. + +```tsx +afterEach(() => { + act(() => jest.runAllTimers()); +}); +``` + +Consider adding a `act(() => jest.runAllTimers());` after your simulated user interaction if you run into a test failure that looks like the following: + +``` +TestingLibraryElementError: Unable to find an accessible element with the role "listbox" +``` + +If you are using real timers instead, you can await a particular state of your app to be reached. If you are using React Testing Library, you can perform a `waitFor` query +to wait for a dialog to appear: + +```tsx +await waitFor(() => { + expect(getByRole('dialog')).toBeInTheDocument(); +}); +``` + +### Simulating user long press + +Some components like Menu support long press operations. Unfortunately, the approach of using the userEvent library to simulate a press event and running timers to hit the +long press internal timer threshold isn't sufficient due to `useLongPress`'s usage of `PointerEvent` and our own detection of `virtual` vs `mouse`/`touch` pointer types. Mock [PointerEvent](https://github.com/adobe/react-spectrum/blob/16ff0efac57eebeb1cd601ab376ce7c58a4e4efd/packages/dev/test-utils/src/events.ts#L70-L103) +globally and use `fireEvent` from `@testing-library/react` to properly simulate these long press events in your tests. +If you are using Jest, you can call our utility to automatically set up and tear down this mock in your test. +Additionally, if you are using fake timers and don't need to control the specific timings around the long press interaction, feel free to use our utility as shown below. + +```tsx +import {fireEvent} from '@testing-library/react'; +import {installPointerEvent, triggerLongPress} from '@react-spectrum/test-utils'; +installPointerEvent(); + +// In test case +let button = getByRole('button'); + +// With fireEvent and specific timing control +fireEvent.pointerDown(el, {pointerType: 'touch'}); +act(() => jest.advanceTimersByTime(800)); +fireEvent.up(el, {pointerType: 'touch'}); + +// With triggerLongPress +triggerLongPress(button); +``` + +### Simulating move event + +Components like ColorArea, ColorSlider, ColorWheel, and Slider each feature a draggable handle that a user can interact with to change the component's value. Similar to long press, the interactions offered by userEvent library aren't sufficient to trigger +the underlying event handlers governing these drag/move operations. [Mock MouseEvent globally](https://github.com/adobe/react-spectrum/blob/16ff0efac57eebeb1cd601ab376ce7c58a4e4efd/packages/dev/test-utils/src/events.ts#L44-L68) and `fireEvent` from `@testing-library/react` to simulate these drag/move events in your tests. +If you are using Jest, you can call our utility to automatically set up and tear down this mock in your test. Additionally, the track dimensions +for the draggable handle should be mocked so that the move operation calculations can be properly computed. + +```tsx +import {fireEvent} from '@testing-library/react'; +import {installMouseEvent} from '@react-spectrum/test-utils'; +installMouseEvent(); + +beforeAll(() => { + jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({top: 0, left: 0, width: 100, height: 10})); +}) + +// In test case +let sliderThumb = getByRole('slider').parentElement; + +// With fireEvent, move thumb from 0 to 50 +fireEvent.mouseDown(thumb, {clientX: 0, pageX: 0}); +fireEvent.mouseMove(thumb, {pageX: 50}); +fireEvent.mouseUp(thumb, {pageX: 50}); +``` + +## React Spectrum test utils + +TODO can't place this next to the header here + + +In addition to the test utilities mentioned above, [@react-spectrum/test-utils](https://www.npmjs.com/package/@react-spectrum/test-utils) re-exports the same test utils available in `@react-aria/test-utils`, including +the ARIA pattern testers. These testers are set of testing utilities that aims to make writing unit tests easier for consumers of React Spectrum. + +### Installation + + + + + Requirements + Please note that this library uses [@testing-library/react@16](https://www.npmjs.com/package/@testing-library/react) and [@testing-library/user-event@14](https://www.npmjs.com/package/@testing-library/user-event). 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-spectrum/test-utils` provides in your test file as shown below. This user only needs to be initialized once and then can be used to generate +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. +See [below](#patterns) for what patterns are currently supported. + +```ts +// YourTest.test.ts +import {screen} from '@testing-library/react'; +import {User} from '@react-spectrum/test-utils'; + +// Provide whatever method of advancing timers you use in your test, this example assumes Jest with fake timers. +// 'interactionType' specifies what mode of interaction should be simulated by the tester +// 'advanceTimer' is used by the tester to advance the timers in the tests for specific interactions (e.g. long press) +let testUtilUser = new User({interactionType: 'mouse', advanceTimer: jest.advanceTimersByTime}); +// ... + +it('my test case', async function () { + // Render your test component/app + render(); + // Initialize the table tester via providing the 'Table' pattern name and the root element of said table + let table = testUtilUser.createTester('Table', {root: screen.getByTestId('test_table')}); + + // ... +}); +``` + +See below for the full definition of the `User` object. + + + +### Patterns + + + +<> + ```ts isInSwitcher + // Combobox.test.ts + import {render} from '@testing-library/react'; + import {User} from '@react-spectrum/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).toBeInTheDocument(); + + let options = comboboxTester.options(); + await comboboxTester.selectOption({option: options[0]}); + expect(comboboxTester.combobox.value).toBe('One'); + expect(comboboxTester.listbox).not.toBeInTheDocument(); + }); + ``` + + + +<> + ```ts isInSwitcher + // Menu.test.ts + import {render} from '@testing-library/react'; + import {User} from '@react-spectrum/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({submenuTrigger: 'Share…'}); + expect(submenuTester.menu).toBeInTheDocument(); + + await submenuTester.selectOption({option: submenuTester.options()[0]}); + expect(submenuTester.menu).not.toBeInTheDocument(); + expect(menuTester.menu).not.toBeInTheDocument(); + }); + ``` + + + +<> + ```ts isInSwitcher + // Picker.test.ts + import {render} from '@testing-library/react'; + import {User} from '@react-spectrum/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'); + + await selectTester.selectOption({option: 'Cat'}); + expect(trigger).toHaveTextContent('Cat'); + }); + ``` + + + +<> + ```ts isInSwitcher + // Table.test.ts + import {render, within} from '@testing-library/react'; + import {User} from '@react-spectrum/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({row: 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); + }); + ``` + + + +<> + ```ts isInSwitcher + // Tabs.test.ts + import {render} from '@testing-library/react'; + import {User} from '@react-spectrum/test-utils'; + + let testUtilUser = new User({interactionType: 'mouse'}); + // ... + + it('Tabs can change selection via keyboard', async function () { + // Render your test component/app and initialize the listbox tester + let {getByTestId} = render( + + ... + + ); + let tabsTester = testUtilUser.createTester('Tabs', {root: getByTestId('test-tabs'), interactionType: 'keyboard'}); + + let tabs = tabsTester.tabs; + expect(tabsTester.selectedTab).toBe(tabs[0]); + + await tabsTester.triggerTab({tab: 1}); + expect(tabsTester.selectedTab).toBe(tabs[1]); + }); + ``` + + + +<> + ```ts isInSwitcher + // Tree.test.ts + import {render, within} from '@testing-library/react'; + import {User} from '@react-spectrum/test-utils'; + + let testUtilUser = new User({interactionType: 'mouse'}); + // ... + + it('TreeView can select a item via keyboard', async function () { + // Render your test component/app and initialize the Tree tester + let {getByTestId} = render( + + ... + + ); + let treeTester = testUtilUser.createTester('Tree', {root: getByTestId('test-tree'), interactionType: 'keyboard'}); + + await treeTester.toggleRowSelection({row: 0}); + expect(treeTester.selectedRows).toHaveLength(1); + expect(within(treeTester.rows[0]).getByRole('checkbox')).toBeChecked(); + + await treeTester.toggleRowSelection({row: 1}); + expect(treeTester.selectedRows).toHaveLength(2); + expect(within(treeTester.rows[1]).getByRole('checkbox')).toBeChecked(); + + await treeTester.toggleRowSelection({row: 0}); + expect(treeTester.selectedRows).toHaveLength(1); + expect(within(treeTester.rows[0]).getByRole('checkbox')).not.toBeChecked(); + }); + ``` + + + + diff --git a/packages/dev/s2-docs/src/ClassAPI.tsx b/packages/dev/s2-docs/src/ClassAPI.tsx index 8d837a3e283..e4a560c5846 100644 --- a/packages/dev/s2-docs/src/ClassAPI.tsx +++ b/packages/dev/s2-docs/src/ClassAPI.tsx @@ -1,14 +1,30 @@ import {InterfaceType, setLinks, TInterface} from './types'; import React from 'react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const wrapper = style({ + paddingX: { + default: 0, + isInSwitcher: 32 + }, + paddingBottom: { + default: 0, + isInSwitcher: 32 + } +}); interface ClassAPIProps { class: TInterface, - links: any + links: any, + // TODO: replace by making this a client component if we can do that + isInSwitcher?: boolean } -export function ClassAPI({class: c, links}: ClassAPIProps) { +export function ClassAPI({class: c, links, isInSwitcher}: ClassAPIProps) { setLinks(links); return ( - +
+ +
); } diff --git a/packages/dev/s2-docs/src/CodeBlock.tsx b/packages/dev/s2-docs/src/CodeBlock.tsx index b2313937506..e123e5a17e2 100644 --- a/packages/dev/s2-docs/src/CodeBlock.tsx +++ b/packages/dev/s2-docs/src/CodeBlock.tsx @@ -28,7 +28,10 @@ const standaloneCode = style({ value: 32 }, padding: '--code-padding-x', - marginY: 32, + marginY: { + default: 32, + isInSwitcher: 0 + }, backgroundColor: 'layer-1', borderRadius: 'xl', font: { @@ -43,10 +46,12 @@ interface CodeBlockProps extends VisualExampleProps { children: string, files?: string[], expanded?: boolean, - hidden?: boolean + hidden?: boolean, + // TODO: if we are fine with this overall approach, perhaps make this a client component isntead and read from context + isInSwitcher?: boolean } -export function CodeBlock({render, children, files, expanded, hidden, ...props}: CodeBlockProps) { +export function CodeBlock({render, children, files, expanded, hidden, isInSwitcher, ...props}: CodeBlockProps) { if (hidden) { return null; } @@ -55,7 +60,7 @@ export function CodeBlock({render, children, files, expanded, hidden, ...props}: if (!render) { return ( -
+      
         {children}
       
); @@ -91,7 +96,7 @@ export function CodeBlock({render, children, files, expanded, hidden, ...props}: component={render} align={props.align} />
- {files + {files ? {content} : content}
@@ -173,6 +178,6 @@ export function getFiles(files: string[]) { } } } - + return fileContents; } diff --git a/packages/dev/s2-docs/src/Layout.tsx b/packages/dev/s2-docs/src/Layout.tsx index 735d3232e5b..487b3bd833d 100644 --- a/packages/dev/s2-docs/src/Layout.tsx +++ b/packages/dev/s2-docs/src/Layout.tsx @@ -27,6 +27,23 @@ const components = { p: ({children, ...props}) =>

{children}

, ul: (props) =>
    , li: ({children, ...props}) =>
  • {children}
  • , + blockquote: ({children, ...props}) => ( +
    + {children} +
    + ), Figure: (props) =>
    , Caption: (props) =>
    , CodeBlock: CodeBlock, @@ -52,14 +69,14 @@ const getTitle = (currentPage: Page): string => { if (explicitTitle && explicitTitle !== currentPage.tableOfContents?.[0]?.title && explicitTitle !== currentPage.name) { return explicitTitle as string; } - + let library = getLibraryLabel(getLibraryFromPage(currentPage)); const pageTitle = currentPage.tableOfContents?.[0]?.title ?? currentPage.name; - + if (currentPage.name === 'index.html' || currentPage.name.endsWith('/index.html')) { return library || 'React Spectrum'; } - + return library ? `${pageTitle} | ${library}` : pageTitle; };