Skip to content

Commit 34c731d

Browse files
feat: user event accessibilityAction (#1930)
1 parent a3e48a7 commit 34c731d

8 files changed

Lines changed: 274 additions & 0 deletions

File tree

docs/api/user-event.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,32 @@ The sequence of events depends on whether the scroll includes an optional moment
293293
- `momentumScrollBegin`
294294
- `scroll` (multiple events)
295295
- `momentumScrollEnd`
296+
297+
## `accessibilityAction()`
298+
299+
```ts
300+
accessibilityAction(
301+
instance: TestInstance,
302+
actionName: string,
303+
): Promise<void>
304+
```
305+
306+
Example
307+
308+
```ts
309+
const user = userEvent.setup();
310+
await user.accessibilityAction(slider, 'increment');
311+
```
312+
313+
Simulates an assistive technology (e.g. a screen reader) triggering the named accessibility action on a given element, invoking its `onAccessibilityAction` handler.
314+
315+
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.
316+
317+
Just like a real assistive technology, the action must be reachable by the user, otherwise an error is thrown:
318+
319+
- the action must be declared in the element's `accessibilityActions` prop,
320+
- the element must not be disabled (via `aria-disabled` or `accessibilityState={{ disabled: true }}`).
321+
322+
### Sequence of events
323+
324+
- `accessibilityAction`

src/event-builder/common.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,19 @@ export function buildBlurEvent() {
6666
},
6767
};
6868
}
69+
70+
/**
71+
* Builds an accessibility action event, as delivered to the `onAccessibilityAction`
72+
* handler when an assistive technology triggers an action.
73+
*
74+
* Experimental values:
75+
* - `{"actionName": "increment"}`
76+
*/
77+
export function buildAccessibilityActionEvent(actionName: string) {
78+
return {
79+
...baseSyntheticEvent(),
80+
nativeEvent: {
81+
actionName,
82+
},
83+
};
84+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as React from 'react';
2+
import type { AccessibilityActionEvent } from 'react-native';
3+
import { View } from 'react-native';
4+
5+
import { render, screen, userEvent } from '../../..';
6+
import { createEventLogger, lastEventPayload } from '../../../test-utils/events';
7+
8+
async function renderViewWithActions(props: React.ComponentProps<typeof View> = {}) {
9+
const { events, logEvent } = createEventLogger();
10+
11+
await render(
12+
<View
13+
testID="view"
14+
accessible
15+
accessibilityActions={[{ name: 'increment' }, { name: 'activate', label: 'Activate' }]}
16+
onAccessibilityAction={(event: AccessibilityActionEvent) =>
17+
logEvent('accessibilityAction')(event)
18+
}
19+
{...props}
20+
/>,
21+
);
22+
23+
return { events };
24+
}
25+
26+
describe('userEvent.accessibilityAction', () => {
27+
test('triggers the onAccessibilityAction handler with the given action name', async () => {
28+
const user = userEvent.setup();
29+
const { events } = await renderViewWithActions();
30+
31+
await user.accessibilityAction(screen.getByTestId('view'), 'increment');
32+
33+
expect(events).toHaveLength(1);
34+
expect(events[0].name).toBe('accessibilityAction');
35+
expect(lastEventPayload(events, 'accessibilityAction').nativeEvent).toEqual({
36+
actionName: 'increment',
37+
});
38+
});
39+
40+
test('supports the direct (setup-less) call form', async () => {
41+
const { events } = await renderViewWithActions();
42+
43+
await userEvent.accessibilityAction(screen.getByTestId('view'), 'activate');
44+
45+
expect(events).toHaveLength(1);
46+
expect(lastEventPayload(events, 'accessibilityAction').nativeEvent.actionName).toBe('activate');
47+
});
48+
49+
test('throws when passed a non-host instance', async () => {
50+
const user = userEvent.setup();
51+
await renderViewWithActions();
52+
53+
// @ts-expect-error intentionally passing a non-host instance
54+
await expect(user.accessibilityAction('not a host instance', 'increment')).rejects.toThrow(
55+
/works only with host instances/,
56+
);
57+
});
58+
59+
test('throws when the action is not declared in accessibilityActions', async () => {
60+
const user = userEvent.setup();
61+
await renderViewWithActions();
62+
63+
await expect(user.accessibilityAction(screen.getByTestId('view'), 'decrement')).rejects.toThrow(
64+
/has no "decrement" accessibility action.*"increment", "activate"/s,
65+
);
66+
});
67+
68+
test('throws when the element declares no accessibility actions', async () => {
69+
const user = userEvent.setup();
70+
await renderViewWithActions({ accessibilityActions: undefined });
71+
72+
await expect(user.accessibilityAction(screen.getByTestId('view'), 'increment')).rejects.toThrow(
73+
/has no accessibility actions/,
74+
);
75+
});
76+
77+
test('throws when the element is disabled', async () => {
78+
const user = userEvent.setup();
79+
await renderViewWithActions({ 'aria-disabled': true });
80+
81+
await expect(user.accessibilityAction(screen.getByTestId('view'), 'increment')).rejects.toThrow(
82+
/on a disabled element/,
83+
);
84+
});
85+
86+
test('throws when the element is disabled via accessibilityState', async () => {
87+
const user = userEvent.setup();
88+
await renderViewWithActions({ accessibilityState: { disabled: true } });
89+
90+
await expect(user.accessibilityAction(screen.getByTestId('view'), 'increment')).rejects.toThrow(
91+
/on a disabled element/,
92+
);
93+
});
94+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { AccessibilityActionInfo } from 'react-native';
2+
import type { TestInstance } from 'test-renderer';
3+
4+
import { buildAccessibilityActionEvent } from '../../event-builder';
5+
import { computeAriaDisabled } from '../../helpers/accessibility';
6+
import { isTestInstance } from '../../helpers/component-tree';
7+
import { ErrorWithStack } from '../../helpers/errors';
8+
import type { StringWithAutocomplete } from '../../types';
9+
import type { UserEventInstance } from '../setup';
10+
import { dispatchEvent } from '../utils';
11+
12+
/**
13+
* Standard accessibility action names recognized by React Native (`activate`,
14+
* `increment`, `decrement`, `longpress`, `magicTap`, `escape`, `expand`,
15+
* `collapse`). Custom action names are supported as well, hence the `string`
16+
* fallback.
17+
*
18+
* @see https://reactnative.dev/docs/accessibility#accessibility-actions
19+
*/
20+
export type AccessibilityActionName = StringWithAutocomplete<
21+
| 'activate'
22+
| 'increment'
23+
| 'decrement'
24+
| 'longpress'
25+
| 'magicTap'
26+
| 'escape'
27+
| 'expand'
28+
| 'collapse'
29+
>;
30+
31+
/**
32+
* Simulate an assistive technology (e.g. screen reader) triggering an
33+
* accessibility action on a given element.
34+
*
35+
* This will call the `onAccessibilityAction` handler with an event carrying the
36+
* given `actionName`.
37+
*
38+
* Like a real assistive technology, the action must be declared in the element's
39+
* `accessibilityActions` prop, and the element must not be disabled. Otherwise an
40+
* error is thrown.
41+
*
42+
* @param instance element to trigger the action on
43+
* @param actionName name of the accessibility action to trigger
44+
*/
45+
export async function accessibilityAction(
46+
this: UserEventInstance,
47+
instance: TestInstance,
48+
actionName: AccessibilityActionName,
49+
): Promise<void> {
50+
if (!isTestInstance(instance)) {
51+
throw new ErrorWithStack(
52+
`accessibilityAction() works only with host instances.`,
53+
accessibilityAction,
54+
);
55+
}
56+
57+
const actions = instance.props.accessibilityActions as
58+
| ReadonlyArray<AccessibilityActionInfo>
59+
| undefined;
60+
61+
if (!actions?.length) {
62+
throw new ErrorWithStack(
63+
`The element has no accessibility actions. Add them using the "accessibilityActions" prop.`,
64+
accessibilityAction,
65+
);
66+
}
67+
68+
if (!actions.some((action) => action.name === actionName)) {
69+
const available = actions.map((action) => `"${action.name}"`).join(', ');
70+
throw new ErrorWithStack(
71+
`The element has no "${actionName}" accessibility action. Available actions: ${available}.`,
72+
accessibilityAction,
73+
);
74+
}
75+
76+
if (computeAriaDisabled(instance)) {
77+
throw new ErrorWithStack(
78+
`Cannot trigger the "${actionName}" accessibility action on a disabled element.`,
79+
accessibilityAction,
80+
);
81+
}
82+
83+
await dispatchEvent(instance, 'accessibilityAction', buildAccessibilityActionEvent(actionName));
84+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { accessibilityAction, AccessibilityActionName } from './accessibility-action';

src/user-event/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TestInstance } from 'test-renderer';
22

3+
import type { AccessibilityActionName } from './accessibility-action';
34
import type { PressOptions } from './press';
45
import type { ScrollToOptions } from './scroll';
56
import { setup } from './setup';
@@ -20,4 +21,6 @@ export const userEvent = {
2021
paste: (instance: TestInstance, text: string) => setup().paste(instance, text),
2122
scrollTo: (instance: TestInstance, options: ScrollToOptions) =>
2223
setup().scrollTo(instance, options),
24+
accessibilityAction: (instance: TestInstance, actionName: AccessibilityActionName) =>
25+
setup().accessibilityAction(instance, actionName),
2326
};

src/user-event/setup/setup.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { TestInstance } from 'test-renderer';
33
import { jestFakeTimersAreEnabled } from '../../helpers/timers';
44
import { validateOptions } from '../../helpers/validate-options';
55
import { wrapAsync } from '../../helpers/wrap-async';
6+
import type { AccessibilityActionName } from '../accessibility-action';
7+
import { accessibilityAction } from '../accessibility-action';
68
import { clear } from '../clear';
79
import { paste } from '../paste';
810
import type { PressOptions } from '../press';
@@ -153,6 +155,21 @@ export interface UserEventInstance {
153155
* @returns
154156
*/
155157
scrollTo: (instance: TestInstance, options: ScrollToOptions) => Promise<void>;
158+
159+
/**
160+
* Simulate an assistive technology (e.g. screen reader) triggering an
161+
* accessibility action on a given element.
162+
*
163+
* The action must be declared in the element's `accessibilityActions` prop and
164+
* the element must not be disabled, otherwise an error is thrown.
165+
*
166+
* @param instance element to trigger the action on
167+
* @param actionName name of the accessibility action to trigger
168+
*/
169+
accessibilityAction: (
170+
instance: TestInstance,
171+
actionName: AccessibilityActionName,
172+
) => Promise<void>;
156173
}
157174

158175
function createInstance(config: UserEventConfig): UserEventInstance {
@@ -168,6 +185,7 @@ function createInstance(config: UserEventConfig): UserEventInstance {
168185
clear: wrapAndBindImpl(instance, clear),
169186
paste: wrapAndBindImpl(instance, paste),
170187
scrollTo: wrapAndBindImpl(instance, scrollTo),
188+
accessibilityAction: wrapAndBindImpl(instance, accessibilityAction),
171189
};
172190

173191
Object.assign(instance, api);

website/docs/14.x/docs/api/events/user-event.mdx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,32 @@ The sequence of events depends on whether the scroll includes an optional moment
294294
- `momentumScrollBegin`
295295
- `scroll` (multiple events)
296296
- `momentumScrollEnd`
297+
298+
## `accessibilityAction()`
299+
300+
```ts
301+
accessibilityAction(
302+
instance: TestInstance,
303+
actionName: string,
304+
): Promise<void>
305+
```
306+
307+
Example
308+
309+
```ts
310+
const user = userEvent.setup();
311+
await user.accessibilityAction(slider, 'increment');
312+
```
313+
314+
Simulates an assistive technology (e.g. a screen reader) triggering the named accessibility action on a given element, invoking its `onAccessibilityAction` handler.
315+
316+
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.
317+
318+
Just like a real assistive technology, the action must be reachable by the user, otherwise an error is thrown:
319+
320+
- the action must be declared in the element's `accessibilityActions` prop,
321+
- the element must not be disabled (via `aria-disabled` or `accessibilityState={{ disabled: true }}`).
322+
323+
### Sequence of events
324+
325+
- `accessibilityAction`

0 commit comments

Comments
 (0)