diff --git a/packages/react-native-gesture-handler/src/__tests__/Pressable.accessibility.test.tsx b/packages/react-native-gesture-handler/src/__tests__/Pressable.accessibility.test.tsx new file mode 100644 index 0000000000..f40d4598be --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/Pressable.accessibility.test.tsx @@ -0,0 +1,246 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { Platform, Text } from 'react-native'; + +import GestureHandlerRootView from '../components/GestureHandlerRootView'; +import LegacyPressable from '../components/Pressable/Pressable'; +import type { + PressableEvent, + PressableProps, +} from '../components/Pressable/PressableProps'; +import Pressable from '../v3/components/Pressable'; + +jest.unmock('../components/Pressable/Pressable'); + +type TestPressableProps = Omit< + PressableProps, + 'block' | 'requireToFail' | 'simultaneousWith' +>; +type AccessibilityActionHandler = NonNullable< + TestPressableProps['onAccessibilityAction'] +>; + +const originalPlatformOS = Platform.OS; +const implementations = [ + ['Pressable', Pressable as React.ComponentType], + [ + 'LegacyPressable', + LegacyPressable as React.ComponentType, + ], +] as const; + +const setPlatform = (platform: typeof Platform.OS) => { + Object.defineProperty(Platform, 'OS', { + configurable: true, + value: platform, + }); +}; + +beforeEach(() => { + setPlatform('android'); +}); + +afterEach(() => { + setPlatform(originalPlatformOS); +}); + +function renderPressable( + Component: React.ComponentType, + props: TestPressableProps, + layout = { x: 0, y: 0, width: 100, height: 40 } +) { + const result = render( + + + Press me + + + ); + const pressable = result.getByTestId('pressable'); + + fireEvent(pressable, 'layout', { + nativeEvent: { layout }, + }); + + return { ...result, pressable }; +} + +describe.each(implementations)( + '%s accessibility actions', + (_name, Component) => { + test('routes activate through the press lifecycle on Android', () => { + const calls: string[] = []; + + const { pressable } = renderPressable(Component, { + onPressIn: () => calls.push('in'), + onPressOut: () => calls.push('out'), + onPress: () => calls.push('press'), + }); + + fireEvent(pressable, 'accessibilityAction', { + nativeEvent: { actionName: 'activate' }, + }); + + expect(calls).toEqual(['in', 'out', 'press']); + }); + + test('uses separate layout-local synthetic events for press in and out', () => { + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + + const { pressable } = renderPressable( + Component, + { + onPressIn, + onPressOut, + onPress: jest.fn(), + }, + { x: 10, y: 20, width: 100, height: 40 } + ); + + fireEvent(pressable, 'accessibilityAction', { + nativeEvent: { actionName: 'activate' }, + }); + + const pressInEvent = onPressIn.mock.calls[0][0]; + const pressOutEvent = onPressOut.mock.calls[0][0]; + + expect(pressInEvent.nativeEvent).toEqual( + expect.objectContaining({ + locationX: 50, + locationY: 20, + pageX: 60, + pageY: 40, + }) + ); + expect(pressOutEvent.nativeEvent.timestamp).toBeGreaterThan( + pressInEvent.nativeEvent.timestamp + ); + }); + + test('routes longpress accessibility action to onLongPress on Android', () => { + const onLongPress = jest.fn(); + const onPress = jest.fn(); + + const { pressable } = renderPressable(Component, { + onLongPress, + onPress, + }); + + fireEvent(pressable, 'accessibilityAction', { + nativeEvent: { actionName: 'longpress' }, + }); + + expect(onLongPress).toHaveBeenCalledTimes(1); + expect(onPress).not.toHaveBeenCalled(); + }); + + test('adds Android press accessibility actions without dropping user actions', () => { + const { pressable } = renderPressable(Component, { + accessibilityActions: [{ name: 'magic', label: 'Magic' }], + onLongPress: jest.fn(), + onPress: jest.fn(), + }); + + expect(pressable.props.accessibilityActions).toEqual([ + { name: 'magic', label: 'Magic' }, + { name: 'activate' }, + { name: 'longpress' }, + ]); + }); + + test('calls user accessibility action handler alongside default press handling', () => { + const onAccessibilityAction = jest.fn(); + const onPress = jest.fn(); + + const { pressable } = renderPressable(Component, { + onAccessibilityAction, + onPress, + }); + + fireEvent(pressable, 'accessibilityAction', { + nativeEvent: { actionName: 'activate' }, + }); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onAccessibilityAction).toHaveBeenCalledTimes(1); + }); + + test('does not duplicate user-handled activate actions', () => { + const onPress = jest.fn(); + const handleAccessibilityAction: AccessibilityActionHandler = (event) => { + if (event.nativeEvent.actionName === 'activate') { + onPress(event); + } + }; + const onAccessibilityAction = jest.fn(handleAccessibilityAction); + + const { pressable } = renderPressable(Component, { + accessibilityActions: [{ name: 'activate' }], + onAccessibilityAction, + onPress, + }); + + fireEvent(pressable, 'accessibilityAction', { + nativeEvent: { actionName: 'activate' }, + }); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onAccessibilityAction).toHaveBeenCalledTimes(1); + }); + + test('does not add or handle press accessibility actions when disabled', () => { + const onPress = jest.fn(); + + const { pressable } = renderPressable(Component, { + accessibilityActions: [{ name: 'magic', label: 'Magic' }], + disabled: true, + onPress, + }); + + expect(pressable.props.accessibilityActions).toEqual([ + { name: 'magic', label: 'Magic' }, + ]); + expect(pressable.props.onAccessibilityAction).toBeUndefined(); + + fireEvent(pressable, 'accessibilityAction', { + nativeEvent: { actionName: 'activate' }, + }); + + expect(onPress).not.toHaveBeenCalled(); + }); + + test('does not add or handle press accessibility actions outside Android', () => { + setPlatform('ios'); + const onPress = jest.fn(); + + const { pressable } = renderPressable(Component, { + accessibilityActions: [{ name: 'magic', label: 'Magic' }], + onPress, + }); + + expect(pressable.props.accessibilityActions).toEqual([ + { name: 'magic', label: 'Magic' }, + ]); + expect(pressable.props.onAccessibilityAction).toBeUndefined(); + + fireEvent(pressable, 'accessibilityAction', { + nativeEvent: { actionName: 'activate' }, + }); + + expect(onPress).not.toHaveBeenCalled(); + }); + + test('keeps the user accessibility action handler unchanged outside Android', () => { + setPlatform('ios'); + const onAccessibilityAction = jest.fn(); + + const { pressable } = renderPressable(Component, { + onAccessibilityAction, + onPress: jest.fn(), + }); + + expect(pressable.props.onAccessibilityAction).toBe(onAccessibilityAction); + }); + } +); diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index 103f4d5f52..a5465093ce 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -28,6 +28,7 @@ import type { } from './PressableProps'; import { getStatesConfig, StateMachineEvent } from './stateDefinitions'; import { PressableStateMachine } from './StateMachine'; +import { usePressableAccessibility } from './usePressableAccessibility'; import { addInsets, gestureToPressableEvent, @@ -64,6 +65,8 @@ const LegacyPressable = (props: LegacyPressableProps) => { android_ripple, disabled, accessible, + accessibilityActions: userAccessibilityActions, + onAccessibilityAction: userOnAccessibilityAction, simultaneousWithExternalGesture, requireExternalGestureToFail, blocksExternalGesture, @@ -200,6 +203,18 @@ const LegacyPressable = (props: LegacyPressableProps) => { [handleFinalize, innerHandlePressIn, onPress, onPressOut] ); + const { accessibilityActions, onAccessibilityAction } = + usePressableAccessibility({ + accessibilityActions: userAccessibilityActions, + dimensions, + disabled, + handlePressIn, + handlePressOut, + onAccessibilityAction: userOnAccessibilityAction, + onLongPress, + onPress, + }); + const stateMachine = useMemo(() => new PressableStateMachine(), []); const isScreenReaderEnabled = useIsScreenReaderEnabled(); @@ -377,6 +392,8 @@ const LegacyPressable = (props: LegacyPressableProps) => { ; + disabled: PressableProps['disabled']; + handlePressIn: (event: PressableEvent) => void; + handlePressOut: (event: PressableEvent) => void; + onAccessibilityAction: PressableProps['onAccessibilityAction']; + onLongPress: PressableProps['onLongPress']; + onPress: PressableProps['onPress']; +}; + +const getAccessibilityActionTargetId = ( + event: Parameters>[0] +) => { + if (event.target == null) { + return 0; + } + + return ( + findNodeHandle( + event.target as unknown as Parameters[0] + ) ?? 0 + ); +}; + +function usePressableAccessibility({ + accessibilityActions: userAccessibilityActions, + dimensions, + disabled, + handlePressIn, + handlePressOut, + onAccessibilityAction: userOnAccessibilityAction, + onLongPress, + onPress, +}: UsePressableAccessibilityParams) { + const shouldUsePressableAccessibilityActions = + Platform.OS === 'android' && + disabled !== true && + (onPress != null || onLongPress != null); + const accessibilityActions = useMemo( + () => + shouldUsePressableAccessibilityActions + ? getPressableAccessibilityActions( + userAccessibilityActions, + onPress, + onLongPress + ) + : userAccessibilityActions, + [ + onLongPress, + onPress, + shouldUsePressableAccessibilityActions, + userAccessibilityActions, + ] + ); + const handleAccessibilityAction = useCallback< + NonNullable + >( + (event) => { + const actionName = event.nativeEvent.actionName; + const shouldHandleAction = + shouldUsePressableAccessibilityActions && + !isUserHandledAccessibilityAction( + actionName, + userAccessibilityActions, + userOnAccessibilityAction + ); + const targetId = getAccessibilityActionTargetId(event); + + if (shouldHandleAction && actionName === 'activate' && onPress) { + const timestamp = Date.now(); + handlePressIn( + makeSyntheticPressableEvent(dimensions.current, timestamp, targetId) + ); + handlePressOut( + makeSyntheticPressableEvent( + dimensions.current, + timestamp + 1, + targetId + ) + ); + } else if (shouldHandleAction && actionName === 'longpress') { + onLongPress?.( + makeSyntheticPressableEvent(dimensions.current, undefined, targetId) + ); + } + + userOnAccessibilityAction?.(event); + }, + [ + dimensions, + handlePressIn, + handlePressOut, + onLongPress, + onPress, + shouldUsePressableAccessibilityActions, + userAccessibilityActions, + userOnAccessibilityAction, + ] + ); + const onAccessibilityAction = shouldUsePressableAccessibilityActions + ? handleAccessibilityAction + : userOnAccessibilityAction; + + return { accessibilityActions, onAccessibilityAction }; +} + +export { usePressableAccessibility }; diff --git a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts index ec83e8b5d8..3fe76b90b2 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts @@ -15,6 +15,7 @@ import type { InnerPressableEvent, PressableDimensions, PressableEvent, + PressableProps, } from './PressableProps'; const numberAsInset = (value: number): Insets => ({ @@ -142,10 +143,85 @@ const gestureTouchToPressableEvent = ( }; }; +const makeSyntheticPressableEvent = ( + dimensions: PressableDimensions, + timestamp: number = Date.now(), + targetId: number = 0 +): PressableEvent => { + const locationX = dimensions.width / 2; + const locationY = dimensions.height / 2; + // AccessibilityActionEvent does not expose native touch coordinates. Use the + // center of the last layout as a layout-local synthetic point. + const pageX = (dimensions.x ?? 0) + locationX; + const pageY = (dimensions.y ?? 0) + locationY; + const pressEvent: InnerPressableEvent = { + identifier: 0, + locationX, + locationY, + pageX, + pageY, + target: targetId, + timestamp, + touches: [], + changedTouches: [], + force: undefined, + }; + + return { + nativeEvent: { + touches: [pressEvent], + changedTouches: [pressEvent], + identifier: pressEvent.identifier, + locationX, + locationY, + pageX, + pageY, + target: targetId, + timestamp, + force: undefined, + }, + }; +}; + +const getPressableAccessibilityActions = ( + accessibilityActions: PressableProps['accessibilityActions'], + onPress: PressableProps['onPress'], + onLongPress: PressableProps['onLongPress'] +) => { + const defaultActions = [ + ...(onPress ? [{ name: 'activate' }] : []), + ...(onLongPress ? [{ name: 'longpress' }] : []), + ]; + + if (defaultActions.length === 0) { + return accessibilityActions; + } + + const actionNames = new Set( + (accessibilityActions ?? []).map((action) => action.name) + ); + + return [ + ...(accessibilityActions ?? []), + ...defaultActions.filter((action) => !actionNames.has(action.name)), + ]; +}; + +const isUserHandledAccessibilityAction = ( + actionName: string, + accessibilityActions: PressableProps['accessibilityActions'], + onAccessibilityAction: PressableProps['onAccessibilityAction'] +) => + onAccessibilityAction != null && + (accessibilityActions ?? []).some((action) => action.name === actionName); + export { addInsets, gestureToPressableEvent, gestureTouchToPressableEvent, + getPressableAccessibilityActions, isTouchWithinInset, + isUserHandledAccessibilityAction, + makeSyntheticPressableEvent, numberAsInset, }; diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index e1ccae5b0f..07823cde08 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -23,6 +23,7 @@ import { StateMachineEvent, } from '../../components/Pressable/stateDefinitions'; import { PressableStateMachine } from '../../components/Pressable/StateMachine'; +import { usePressableAccessibility } from '../../components/Pressable/usePressableAccessibility'; import { addInsets, gestureToPressableEvent, @@ -67,6 +68,8 @@ const Pressable = (props: PressableProps) => { android_ripple, disabled, accessible, + accessibilityActions: userAccessibilityActions, + onAccessibilityAction: userOnAccessibilityAction, simultaneousWith, requireToFail, block, @@ -199,6 +202,18 @@ const Pressable = (props: PressableProps) => { [handleFinalize, innerHandlePressIn, onPress, onPressOut] ); + const { accessibilityActions, onAccessibilityAction } = + usePressableAccessibility({ + accessibilityActions: userAccessibilityActions, + dimensions, + disabled, + handlePressIn, + handlePressOut, + onAccessibilityAction: userOnAccessibilityAction, + onLongPress, + onPress, + }); + const stateMachine = useMemo(() => new PressableStateMachine(), []); const isScreenReaderEnabled = useIsScreenReaderEnabled(); @@ -369,6 +384,8 @@ const Pressable = (props: PressableProps) => {