diff --git a/docs/api/user-event.md b/docs/api/user-event.md index 04e4375c7..7991ec3f3 100644 --- a/docs/api/user-event.md +++ b/docs/api/user-event.md @@ -293,3 +293,32 @@ The sequence of events depends on whether the scroll includes an optional moment - `momentumScrollBegin` - `scroll` (multiple events) - `momentumScrollEnd` + +## `accessibilityAction()` + +```ts +accessibilityAction( + instance: TestInstance, + actionName: string, +): Promise +``` + +Example + +```ts +const user = userEvent.setup(); +await user.accessibilityAction(slider, 'increment'); +``` + +Simulates an assistive technology (e.g. a screen reader) triggering the named accessibility action on a given element, invoking its `onAccessibilityAction` handler. + +The `actionName` autocompletes the [standard React Native actions](https://reactnative.dev/docs/accessibility#accessibility-actions) (`activate`, `increment`, `decrement`, `longpress`, `magicTap`, `escape`, `expand`, `collapse`), but any custom action name is accepted. + +Just like a real assistive technology, the action must be reachable by the user, otherwise an error is thrown: + +- the action must be declared in the element's `accessibilityActions` prop, +- the element must not be disabled (via `aria-disabled` or `accessibilityState={{ disabled: true }}`). + +### Sequence of events + +- `accessibilityAction` diff --git a/src/event-builder/common.ts b/src/event-builder/common.ts index 43b6606ae..9224faa0c 100644 --- a/src/event-builder/common.ts +++ b/src/event-builder/common.ts @@ -66,3 +66,19 @@ export function buildBlurEvent() { }, }; } + +/** + * Builds an accessibility action event, as delivered to the `onAccessibilityAction` + * handler when an assistive technology triggers an action. + * + * Experimental values: + * - `{"actionName": "increment"}` + */ +export function buildAccessibilityActionEvent(actionName: string) { + return { + ...baseSyntheticEvent(), + nativeEvent: { + actionName, + }, + }; +} diff --git a/src/user-event/accessibility-action/__tests__/accessibility-action.test.tsx b/src/user-event/accessibility-action/__tests__/accessibility-action.test.tsx new file mode 100644 index 000000000..8421504d6 --- /dev/null +++ b/src/user-event/accessibility-action/__tests__/accessibility-action.test.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import type { AccessibilityActionEvent } from 'react-native'; +import { View } from 'react-native'; + +import { render, screen, userEvent } from '../../..'; +import { createEventLogger, lastEventPayload } from '../../../test-utils/events'; + +async function renderViewWithActions(props: React.ComponentProps = {}) { + const { events, logEvent } = createEventLogger(); + + await render( + + logEvent('accessibilityAction')(event) + } + {...props} + />, + ); + + return { events }; +} + +describe('userEvent.accessibilityAction', () => { + test('triggers the onAccessibilityAction handler with the given action name', async () => { + const user = userEvent.setup(); + const { events } = await renderViewWithActions(); + + await user.accessibilityAction(screen.getByTestId('view'), 'increment'); + + expect(events).toHaveLength(1); + expect(events[0].name).toBe('accessibilityAction'); + expect(lastEventPayload(events, 'accessibilityAction').nativeEvent).toEqual({ + actionName: 'increment', + }); + }); + + test('supports the direct (setup-less) call form', async () => { + const { events } = await renderViewWithActions(); + + await userEvent.accessibilityAction(screen.getByTestId('view'), 'activate'); + + expect(events).toHaveLength(1); + expect(lastEventPayload(events, 'accessibilityAction').nativeEvent.actionName).toBe('activate'); + }); + + test('throws when passed a non-host instance', async () => { + const user = userEvent.setup(); + await renderViewWithActions(); + + // @ts-expect-error intentionally passing a non-host instance + await expect(user.accessibilityAction('not a host instance', 'increment')).rejects.toThrow( + /works only with host instances/, + ); + }); + + test('throws when the action is not declared in accessibilityActions', async () => { + const user = userEvent.setup(); + await renderViewWithActions(); + + await expect(user.accessibilityAction(screen.getByTestId('view'), 'decrement')).rejects.toThrow( + /has no "decrement" accessibility action.*"increment", "activate"/s, + ); + }); + + test('throws when the element declares no accessibility actions', async () => { + const user = userEvent.setup(); + await renderViewWithActions({ accessibilityActions: undefined }); + + await expect(user.accessibilityAction(screen.getByTestId('view'), 'increment')).rejects.toThrow( + /has no accessibility actions/, + ); + }); + + test('throws when the element is disabled', async () => { + const user = userEvent.setup(); + await renderViewWithActions({ 'aria-disabled': true }); + + await expect(user.accessibilityAction(screen.getByTestId('view'), 'increment')).rejects.toThrow( + /on a disabled element/, + ); + }); + + test('throws when the element is disabled via accessibilityState', async () => { + const user = userEvent.setup(); + await renderViewWithActions({ accessibilityState: { disabled: true } }); + + await expect(user.accessibilityAction(screen.getByTestId('view'), 'increment')).rejects.toThrow( + /on a disabled element/, + ); + }); +}); diff --git a/src/user-event/accessibility-action/accessibility-action.ts b/src/user-event/accessibility-action/accessibility-action.ts new file mode 100644 index 000000000..bfa40c6fd --- /dev/null +++ b/src/user-event/accessibility-action/accessibility-action.ts @@ -0,0 +1,84 @@ +import type { AccessibilityActionInfo } from 'react-native'; +import type { TestInstance } from 'test-renderer'; + +import { buildAccessibilityActionEvent } from '../../event-builder'; +import { computeAriaDisabled } from '../../helpers/accessibility'; +import { isTestInstance } from '../../helpers/component-tree'; +import { ErrorWithStack } from '../../helpers/errors'; +import type { StringWithAutocomplete } from '../../types'; +import type { UserEventInstance } from '../setup'; +import { dispatchEvent } from '../utils'; + +/** + * Standard accessibility action names recognized by React Native (`activate`, + * `increment`, `decrement`, `longpress`, `magicTap`, `escape`, `expand`, + * `collapse`). Custom action names are supported as well, hence the `string` + * fallback. + * + * @see https://reactnative.dev/docs/accessibility#accessibility-actions + */ +export type AccessibilityActionName = StringWithAutocomplete< + | 'activate' + | 'increment' + | 'decrement' + | 'longpress' + | 'magicTap' + | 'escape' + | 'expand' + | 'collapse' +>; + +/** + * Simulate an assistive technology (e.g. screen reader) triggering an + * accessibility action on a given element. + * + * This will call the `onAccessibilityAction` handler with an event carrying the + * given `actionName`. + * + * Like a real assistive technology, the action must be declared in the element's + * `accessibilityActions` prop, and the element must not be disabled. Otherwise an + * error is thrown. + * + * @param instance element to trigger the action on + * @param actionName name of the accessibility action to trigger + */ +export async function accessibilityAction( + this: UserEventInstance, + instance: TestInstance, + actionName: AccessibilityActionName, +): Promise { + if (!isTestInstance(instance)) { + throw new ErrorWithStack( + `accessibilityAction() works only with host instances.`, + accessibilityAction, + ); + } + + const actions = instance.props.accessibilityActions as + | ReadonlyArray + | undefined; + + if (!actions?.length) { + throw new ErrorWithStack( + `The element has no accessibility actions. Add them using the "accessibilityActions" prop.`, + accessibilityAction, + ); + } + + if (!actions.some((action) => action.name === actionName)) { + const available = actions.map((action) => `"${action.name}"`).join(', '); + throw new ErrorWithStack( + `The element has no "${actionName}" accessibility action. Available actions: ${available}.`, + accessibilityAction, + ); + } + + if (computeAriaDisabled(instance)) { + throw new ErrorWithStack( + `Cannot trigger the "${actionName}" accessibility action on a disabled element.`, + accessibilityAction, + ); + } + + await dispatchEvent(instance, 'accessibilityAction', buildAccessibilityActionEvent(actionName)); +} diff --git a/src/user-event/accessibility-action/index.ts b/src/user-event/accessibility-action/index.ts new file mode 100644 index 000000000..c84e7b3d9 --- /dev/null +++ b/src/user-event/accessibility-action/index.ts @@ -0,0 +1 @@ +export { accessibilityAction, AccessibilityActionName } from './accessibility-action'; diff --git a/src/user-event/index.ts b/src/user-event/index.ts index d2d0a9622..b19451cdf 100644 --- a/src/user-event/index.ts +++ b/src/user-event/index.ts @@ -1,5 +1,6 @@ import type { TestInstance } from 'test-renderer'; +import type { AccessibilityActionName } from './accessibility-action'; import type { PressOptions } from './press'; import type { ScrollToOptions } from './scroll'; import { setup } from './setup'; @@ -20,4 +21,6 @@ export const userEvent = { paste: (instance: TestInstance, text: string) => setup().paste(instance, text), scrollTo: (instance: TestInstance, options: ScrollToOptions) => setup().scrollTo(instance, options), + accessibilityAction: (instance: TestInstance, actionName: AccessibilityActionName) => + setup().accessibilityAction(instance, actionName), }; diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index 336d19f2a..feb75d32d 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -3,6 +3,8 @@ import type { TestInstance } from 'test-renderer'; import { jestFakeTimersAreEnabled } from '../../helpers/timers'; import { validateOptions } from '../../helpers/validate-options'; import { wrapAsync } from '../../helpers/wrap-async'; +import type { AccessibilityActionName } from '../accessibility-action'; +import { accessibilityAction } from '../accessibility-action'; import { clear } from '../clear'; import { paste } from '../paste'; import type { PressOptions } from '../press'; @@ -153,6 +155,21 @@ export interface UserEventInstance { * @returns */ scrollTo: (instance: TestInstance, options: ScrollToOptions) => Promise; + + /** + * Simulate an assistive technology (e.g. screen reader) triggering an + * accessibility action on a given element. + * + * The action must be declared in the element's `accessibilityActions` prop and + * the element must not be disabled, otherwise an error is thrown. + * + * @param instance element to trigger the action on + * @param actionName name of the accessibility action to trigger + */ + accessibilityAction: ( + instance: TestInstance, + actionName: AccessibilityActionName, + ) => Promise; } function createInstance(config: UserEventConfig): UserEventInstance { @@ -168,6 +185,7 @@ function createInstance(config: UserEventConfig): UserEventInstance { clear: wrapAndBindImpl(instance, clear), paste: wrapAndBindImpl(instance, paste), scrollTo: wrapAndBindImpl(instance, scrollTo), + accessibilityAction: wrapAndBindImpl(instance, accessibilityAction), }; Object.assign(instance, api); diff --git a/website/docs/14.x/docs/api/events/user-event.mdx b/website/docs/14.x/docs/api/events/user-event.mdx index 198dc0c13..ce9b153b7 100644 --- a/website/docs/14.x/docs/api/events/user-event.mdx +++ b/website/docs/14.x/docs/api/events/user-event.mdx @@ -294,3 +294,32 @@ The sequence of events depends on whether the scroll includes an optional moment - `momentumScrollBegin` - `scroll` (multiple events) - `momentumScrollEnd` + +## `accessibilityAction()` + +```ts +accessibilityAction( + instance: TestInstance, + actionName: string, +): Promise +``` + +Example + +```ts +const user = userEvent.setup(); +await user.accessibilityAction(slider, 'increment'); +``` + +Simulates an assistive technology (e.g. a screen reader) triggering the named accessibility action on a given element, invoking its `onAccessibilityAction` handler. + +The `actionName` autocompletes the [standard React Native actions](https://reactnative.dev/docs/accessibility#accessibility-actions) (`activate`, `increment`, `decrement`, `longpress`, `magicTap`, `escape`, `expand`, `collapse`), but any custom action name is accepted. + +Just like a real assistive technology, the action must be reachable by the user, otherwise an error is thrown: + +- the action must be declared in the element's `accessibilityActions` prop, +- the element must not be disabled (via `aria-disabled` or `accessibilityState={{ disabled: true }}`). + +### Sequence of events + +- `accessibilityAction`