Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/api/user-event.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
```

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`
16 changes: 16 additions & 0 deletions src/event-builder/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
}
Original file line number Diff line number Diff line change
@@ -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<typeof View> = {}) {
const { events, logEvent } = createEventLogger();

await render(
<View
testID="view"
accessible
accessibilityActions={[{ name: 'increment' }, { name: 'activate', label: 'Activate' }]}
onAccessibilityAction={(event: AccessibilityActionEvent) =>
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/,
);
});
});
84 changes: 84 additions & 0 deletions src/user-event/accessibility-action/accessibility-action.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (!isTestInstance(instance)) {
throw new ErrorWithStack(
`accessibilityAction() works only with host instances.`,
accessibilityAction,
);
}

const actions = instance.props.accessibilityActions as
| ReadonlyArray<AccessibilityActionInfo>
| 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));
}
1 change: 1 addition & 0 deletions src/user-event/accessibility-action/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { accessibilityAction, AccessibilityActionName } from './accessibility-action';
3 changes: 3 additions & 0 deletions src/user-event/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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),
};
18 changes: 18 additions & 0 deletions src/user-event/setup/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -153,6 +155,21 @@ export interface UserEventInstance {
* @returns
*/
scrollTo: (instance: TestInstance, options: ScrollToOptions) => Promise<void>;

/**
* 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<void>;
}

function createInstance(config: UserEventConfig): UserEventInstance {
Expand All @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions website/docs/14.x/docs/api/events/user-event.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
```

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`