Skip to content

Commit 2136c9c

Browse files
committed
Address Pressable accessibility review feedback
1 parent 18e6f7b commit 2136c9c

6 files changed

Lines changed: 259 additions & 129 deletions

File tree

packages/react-native-gesture-handler/src/__tests__/Pressable.accessibility.test.tsx

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { Platform, Text } from 'react-native';
44

55
import GestureHandlerRootView from '../components/GestureHandlerRootView';
66
import LegacyPressable from '../components/Pressable/Pressable';
7-
import type { PressableProps } from '../components/Pressable/PressableProps';
7+
import type {
8+
PressableEvent,
9+
PressableProps,
10+
} from '../components/Pressable/PressableProps';
811
import Pressable from '../v3/components/Pressable';
912

1013
jest.unmock('../components/Pressable/Pressable');
@@ -26,23 +29,25 @@ const implementations = [
2629
],
2730
] as const;
2831

29-
beforeEach(() => {
32+
const setPlatform = (platform: typeof Platform.OS) => {
3033
Object.defineProperty(Platform, 'OS', {
3134
configurable: true,
32-
value: 'android',
35+
value: platform,
3336
});
37+
};
38+
39+
beforeEach(() => {
40+
setPlatform('android');
3441
});
3542

3643
afterEach(() => {
37-
Object.defineProperty(Platform, 'OS', {
38-
configurable: true,
39-
value: originalPlatformOS,
40-
});
44+
setPlatform(originalPlatformOS);
4145
});
4246

4347
function renderPressable(
4448
Component: React.ComponentType<TestPressableProps>,
45-
props: TestPressableProps
49+
props: TestPressableProps,
50+
layout = { x: 0, y: 0, width: 100, height: 40 }
4651
) {
4752
const result = render(
4853
<GestureHandlerRootView>
@@ -54,7 +59,7 @@ function renderPressable(
5459
const pressable = result.getByTestId('pressable');
5560

5661
fireEvent(pressable, 'layout', {
57-
nativeEvent: { layout: { x: 0, y: 0, width: 100, height: 40 } },
62+
nativeEvent: { layout },
5863
});
5964

6065
return { ...result, pressable };
@@ -79,6 +84,40 @@ describe.each(implementations)(
7984
expect(calls).toEqual(['in', 'out', 'press']);
8085
});
8186

87+
test('uses separate layout-local synthetic events for press in and out', () => {
88+
const onPressIn = jest.fn<void, [PressableEvent]>();
89+
const onPressOut = jest.fn<void, [PressableEvent]>();
90+
91+
const { pressable } = renderPressable(
92+
Component,
93+
{
94+
onPressIn,
95+
onPressOut,
96+
onPress: jest.fn(),
97+
},
98+
{ x: 10, y: 20, width: 100, height: 40 }
99+
);
100+
101+
fireEvent(pressable, 'accessibilityAction', {
102+
nativeEvent: { actionName: 'activate' },
103+
});
104+
105+
const pressInEvent = onPressIn.mock.calls[0][0];
106+
const pressOutEvent = onPressOut.mock.calls[0][0];
107+
108+
expect(pressInEvent.nativeEvent).toEqual(
109+
expect.objectContaining({
110+
locationX: 50,
111+
locationY: 20,
112+
pageX: 60,
113+
pageY: 40,
114+
})
115+
);
116+
expect(pressOutEvent.nativeEvent.timestamp).toBeGreaterThan(
117+
pressInEvent.nativeEvent.timestamp
118+
);
119+
});
120+
82121
test('routes longpress accessibility action to onLongPress on Android', () => {
83122
const onLongPress = jest.fn();
84123
const onPress = jest.fn();
@@ -149,5 +188,59 @@ describe.each(implementations)(
149188
expect(onPress).toHaveBeenCalledTimes(1);
150189
expect(onAccessibilityAction).toHaveBeenCalledTimes(1);
151190
});
191+
192+
test('does not add or handle press accessibility actions when disabled', () => {
193+
const onPress = jest.fn();
194+
195+
const { pressable } = renderPressable(Component, {
196+
accessibilityActions: [{ name: 'magic', label: 'Magic' }],
197+
disabled: true,
198+
onPress,
199+
});
200+
201+
expect(pressable.props.accessibilityActions).toEqual([
202+
{ name: 'magic', label: 'Magic' },
203+
]);
204+
expect(pressable.props.onAccessibilityAction).toBeUndefined();
205+
206+
fireEvent(pressable, 'accessibilityAction', {
207+
nativeEvent: { actionName: 'activate' },
208+
});
209+
210+
expect(onPress).not.toHaveBeenCalled();
211+
});
212+
213+
test('does not add or handle press accessibility actions outside Android', () => {
214+
setPlatform('ios');
215+
const onPress = jest.fn();
216+
217+
const { pressable } = renderPressable(Component, {
218+
accessibilityActions: [{ name: 'magic', label: 'Magic' }],
219+
onPress,
220+
});
221+
222+
expect(pressable.props.accessibilityActions).toEqual([
223+
{ name: 'magic', label: 'Magic' },
224+
]);
225+
expect(pressable.props.onAccessibilityAction).toBeUndefined();
226+
227+
fireEvent(pressable, 'accessibilityAction', {
228+
nativeEvent: { actionName: 'activate' },
229+
});
230+
231+
expect(onPress).not.toHaveBeenCalled();
232+
});
233+
234+
test('keeps the user accessibility action handler unchanged outside Android', () => {
235+
setPlatform('ios');
236+
const onAccessibilityAction = jest.fn();
237+
238+
const { pressable } = renderPressable(Component, {
239+
onAccessibilityAction,
240+
onPress: jest.fn(),
241+
});
242+
243+
expect(pressable.props.onAccessibilityAction).toBe(onAccessibilityAction);
244+
});
152245
}
153246
);

packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx

Lines changed: 8 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,12 @@ import type {
2828
} from './PressableProps';
2929
import { getStatesConfig, StateMachineEvent } from './stateDefinitions';
3030
import { PressableStateMachine } from './StateMachine';
31+
import { usePressableAccessibility } from './usePressableAccessibility';
3132
import {
3233
addInsets,
3334
gestureToPressableEvent,
3435
gestureTouchToPressableEvent,
35-
getPressableAccessibilityActions,
3636
isTouchWithinInset,
37-
isUserHandledAccessibilityAction,
38-
makeSyntheticPressableEvent,
3937
numberAsInset,
4038
} from './utils';
4139

@@ -205,63 +203,17 @@ const LegacyPressable = (props: LegacyPressableProps) => {
205203
[handleFinalize, innerHandlePressIn, onPress, onPressOut]
206204
);
207205

208-
const shouldUsePressableAccessibilityActions =
209-
Platform.OS === 'android' &&
210-
disabled !== true &&
211-
(onPress != null || onLongPress != null);
212-
const accessibilityActions = useMemo(
213-
() =>
214-
shouldUsePressableAccessibilityActions
215-
? getPressableAccessibilityActions(
216-
userAccessibilityActions,
217-
onPress,
218-
onLongPress
219-
)
220-
: userAccessibilityActions,
221-
[
222-
onLongPress,
223-
onPress,
224-
shouldUsePressableAccessibilityActions,
225-
userAccessibilityActions,
226-
]
227-
);
228-
const handleAccessibilityAction = useCallback<
229-
NonNullable<LegacyPressableProps['onAccessibilityAction']>
230-
>(
231-
(event) => {
232-
const actionName = event.nativeEvent.actionName;
233-
const shouldHandleAction =
234-
shouldUsePressableAccessibilityActions &&
235-
!isUserHandledAccessibilityAction(
236-
actionName,
237-
userAccessibilityActions,
238-
userOnAccessibilityAction
239-
);
240-
241-
if (shouldHandleAction && actionName === 'activate' && onPress) {
242-
const pressableEvent = makeSyntheticPressableEvent(dimensions.current);
243-
handlePressIn(pressableEvent);
244-
handlePressOut(pressableEvent);
245-
} else if (shouldHandleAction && actionName === 'longpress') {
246-
onLongPress?.(makeSyntheticPressableEvent(dimensions.current));
247-
}
248-
249-
userOnAccessibilityAction?.(event);
250-
},
251-
[
206+
const { accessibilityActions, onAccessibilityAction } =
207+
usePressableAccessibility({
208+
accessibilityActions: userAccessibilityActions,
209+
dimensions,
210+
disabled,
252211
handlePressIn,
253212
handlePressOut,
213+
onAccessibilityAction: userOnAccessibilityAction,
254214
onLongPress,
255215
onPress,
256-
shouldUsePressableAccessibilityActions,
257-
userAccessibilityActions,
258-
userOnAccessibilityAction,
259-
]
260-
);
261-
const onAccessibilityAction =
262-
shouldUsePressableAccessibilityActions || userOnAccessibilityAction
263-
? handleAccessibilityAction
264-
: undefined;
216+
});
265217

266218
const stateMachine = useMemo(() => new PressableStateMachine(), []);
267219
const isScreenReaderEnabled = useIsScreenReaderEnabled();

packages/react-native-gesture-handler/src/components/Pressable/PressableProps.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import type {
1212
import type { AnyGesture } from '../../v3/types';
1313
import type { RelationPropType } from '../utils';
1414

15-
export type PressableDimensions = { width: number; height: number };
15+
export type PressableDimensions = {
16+
x?: number;
17+
y?: number;
18+
width: number;
19+
height: number;
20+
};
1621

1722
export type PressableStateCallbackType = RNPressableStateCallbackType;
1823
export type PressableAndroidRippleConfig = RNPressableAndroidRippleConfig;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { MutableRefObject } from 'react';
2+
import { useCallback, useMemo } from 'react';
3+
import { Platform } from 'react-native';
4+
5+
import findNodeHandle from '../../findNodeHandle';
6+
import type {
7+
PressableDimensions,
8+
PressableEvent,
9+
PressableProps,
10+
} from './PressableProps';
11+
import {
12+
getPressableAccessibilityActions,
13+
isUserHandledAccessibilityAction,
14+
makeSyntheticPressableEvent,
15+
} from './utils';
16+
17+
type UsePressableAccessibilityParams = {
18+
accessibilityActions: PressableProps['accessibilityActions'];
19+
dimensions: MutableRefObject<PressableDimensions>;
20+
disabled: PressableProps['disabled'];
21+
handlePressIn: (event: PressableEvent) => void;
22+
handlePressOut: (event: PressableEvent) => void;
23+
onAccessibilityAction: PressableProps['onAccessibilityAction'];
24+
onLongPress: PressableProps['onLongPress'];
25+
onPress: PressableProps['onPress'];
26+
};
27+
28+
const getAccessibilityActionTargetId = (
29+
event: Parameters<NonNullable<PressableProps['onAccessibilityAction']>>[0]
30+
) => {
31+
if (event.target == null) {
32+
return 0;
33+
}
34+
35+
return (
36+
findNodeHandle(
37+
event.target as unknown as Parameters<typeof findNodeHandle>[0]
38+
) ?? 0
39+
);
40+
};
41+
42+
function usePressableAccessibility({
43+
accessibilityActions: userAccessibilityActions,
44+
dimensions,
45+
disabled,
46+
handlePressIn,
47+
handlePressOut,
48+
onAccessibilityAction: userOnAccessibilityAction,
49+
onLongPress,
50+
onPress,
51+
}: UsePressableAccessibilityParams) {
52+
const shouldUsePressableAccessibilityActions =
53+
Platform.OS === 'android' &&
54+
disabled !== true &&
55+
(onPress != null || onLongPress != null);
56+
const accessibilityActions = useMemo(
57+
() =>
58+
shouldUsePressableAccessibilityActions
59+
? getPressableAccessibilityActions(
60+
userAccessibilityActions,
61+
onPress,
62+
onLongPress
63+
)
64+
: userAccessibilityActions,
65+
[
66+
onLongPress,
67+
onPress,
68+
shouldUsePressableAccessibilityActions,
69+
userAccessibilityActions,
70+
]
71+
);
72+
const handleAccessibilityAction = useCallback<
73+
NonNullable<PressableProps['onAccessibilityAction']>
74+
>(
75+
(event) => {
76+
const actionName = event.nativeEvent.actionName;
77+
const shouldHandleAction =
78+
shouldUsePressableAccessibilityActions &&
79+
!isUserHandledAccessibilityAction(
80+
actionName,
81+
userAccessibilityActions,
82+
userOnAccessibilityAction
83+
);
84+
const targetId = getAccessibilityActionTargetId(event);
85+
86+
if (shouldHandleAction && actionName === 'activate' && onPress) {
87+
const timestamp = Date.now();
88+
handlePressIn(
89+
makeSyntheticPressableEvent(dimensions.current, timestamp, targetId)
90+
);
91+
handlePressOut(
92+
makeSyntheticPressableEvent(
93+
dimensions.current,
94+
timestamp + 1,
95+
targetId
96+
)
97+
);
98+
} else if (shouldHandleAction && actionName === 'longpress') {
99+
onLongPress?.(
100+
makeSyntheticPressableEvent(dimensions.current, undefined, targetId)
101+
);
102+
}
103+
104+
userOnAccessibilityAction?.(event);
105+
},
106+
[
107+
dimensions,
108+
handlePressIn,
109+
handlePressOut,
110+
onLongPress,
111+
onPress,
112+
shouldUsePressableAccessibilityActions,
113+
userAccessibilityActions,
114+
userOnAccessibilityAction,
115+
]
116+
);
117+
const onAccessibilityAction = shouldUsePressableAccessibilityActions
118+
? handleAccessibilityAction
119+
: userOnAccessibilityAction;
120+
121+
return { accessibilityActions, onAccessibilityAction };
122+
}
123+
124+
export { usePressableAccessibility };

0 commit comments

Comments
 (0)