From dc0cafb8d538279bd2a0f124d408a2da8520057c Mon Sep 17 00:00:00 2001 From: Dmitry Zhigulin <104712148+faciledictu@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:38:50 -0800 Subject: [PATCH 1/2] fix(Touchable): pass role and the missing aria props to the host view (#59378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Part of https://github.com/react/react-native/issues/59377. `Touchable*` build their host view props from a fixed list, and some props they accept in their types never reach it: - `role`: none of `TouchableOpacity`, `TouchableHighlight`, `TouchableNativeFeedback`, `TouchableWithoutFeedback` passes it, so `` has no role while `accessibilityRole="button"` works. - `TouchableWithoutFeedback` drops `aria-label`. - `TouchableHighlight` drops `aria-busy`, `aria-checked`, `aria-disabled`, `aria-expanded` and `aria-selected`; the other three already map them. This change passes `role` from all four, maps `aria-label` in `TouchableWithoutFeedback` (it wins over `accessibilityLabel`, as in `View`, and the child keeps its own label when neither is set) and maps the state props in `TouchableHighlight` the same way `TouchableOpacity` does. `aria-labelledby` is left to https://github.com/react/react-native/issues/58173. The shared `rolePropSuite` now also runs for `TouchableNativeFeedback` and `TouchableWithoutFeedback`; before, it ran only for `Text`. It cannot run for `TouchableOpacity` and `TouchableHighlight`: Fantom reads the debug props of a view, and `AccessibilityProps::getDebugProps` does not include `role`, so `role` on their host view is checked on a device instead (pairs G / H of the reproducer). ## Changelog: [GENERAL] [FIXED] - `role` on `Touchable*`, `aria-label` on `TouchableWithoutFeedback` and `aria-*` state props on `TouchableHighlight` reach the native view Pull Request resolved: https://github.com/react/react-native/pull/59378 Test Plan: - `yarn fantom packages/react-native/Libraries/Components/Touchable`: 4 suites, 272 tests passed. (Locally with `CXXFLAGS=-Wno-deprecated-literal-operator`: Xcode 27.1 clang rejects the `operator "" _json` syntax of the bundled nlohmann_json 3.11.2 under `-Werror`; unrelated to this change.) - `yarn flow-check`: no errors. `yarn eslint` on the changed files: no warnings. - On a device: the reproducer https://github.com/faciledictu/rn-aria-props-repro, pairs G–L. Before: on Android 17, G is `android.view.ViewGroup`, I's `content-desc` is the child's "×", K has no `checked`; on iOS 27, G has no Button trait, I's label is "×", K's value has no "checked". After, on iOS 27: G has the Button trait, I's label is "Close I", K's value is "checkbox, checked". After, on Android 17: G is `android.widget.Button`, I's `content-desc` is "Close I", K is `checkable="true" checked="true"`. Each matches its `accessibility*` control (H, J, L). - Snapshot refresh (2026-11-05): `js1 test --runInBand --watch=false --runTestsByPath` on `MobileHomeBAMFilterBar`, `MobileHomeBAMUrgencyPillter`, `TWTabPager`, `OCReferralsSurface` and `ActivityLogClearButton`: 5 suites, 25 tests and 17 snapshots passed after regenerating 14 snapshots. `TouchableHighlight.js` is unchanged from the original imported version. - `arc lint -a`: no lint issues. Differential Revision: D129326374 Pulled By: javache --- .../Touchable/TouchableHighlight.js | 43 ++++++++-- .../Touchable/TouchableNativeFeedback.js | 1 + .../Components/Touchable/TouchableOpacity.js | 1 + .../Touchable/TouchableWithoutFeedback.js | 6 ++ .../__tests__/TouchableHighlight-itest.js | 79 +++++++++++++++++++ .../TouchableNativeFeedback-itest.js | 13 +++ .../TouchableWithoutFeedback-itest.js | 57 ++++++++++++- 7 files changed, 191 insertions(+), 9 deletions(-) diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js index e8a01c46ff2c..854c9adf17ac 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js @@ -222,13 +222,39 @@ class TouchableHighlightImpl extends React.Component< const {onBlur, onFocus, ...eventHandlersWithoutBlurAndFocus} = this.state.pressability.getEventHandlers(); - const accessibilityState: ?AccessibilityState = - this.props.disabled != null - ? { - ...this.props.accessibilityState, - disabled: this.props.disabled, - } - : this.props.accessibilityState; + const { + accessibilityState, + 'aria-busy': ariaBusy, + 'aria-checked': ariaChecked, + 'aria-disabled': ariaDisabled, + 'aria-expanded': ariaExpanded, + 'aria-selected': ariaSelected, + } = this.props; + + let _accessibilityState: ?AccessibilityState; + if ( + accessibilityState != null || + ariaBusy != null || + ariaChecked != null || + ariaDisabled != null || + ariaExpanded != null || + ariaSelected != null + ) { + _accessibilityState = { + busy: ariaBusy ?? accessibilityState?.busy, + checked: ariaChecked ?? accessibilityState?.checked, + disabled: ariaDisabled ?? accessibilityState?.disabled, + expanded: ariaExpanded ?? accessibilityState?.expanded, + selected: ariaSelected ?? accessibilityState?.selected, + }; + } + + if (this.props.disabled != null) { + _accessibilityState = { + ..._accessibilityState, + disabled: this.props.disabled, + }; + } const accessibilityValue = { max: this.props['aria-valuemax'] ?? this.props.accessibilityValue?.max, @@ -251,7 +277,8 @@ class TouchableHighlightImpl extends React.Component< accessibilityHint={this.props.accessibilityHint} accessibilityLanguage={this.props.accessibilityLanguage} accessibilityRole={this.props.accessibilityRole} - accessibilityState={accessibilityState} + role={this.props.role} + accessibilityState={_accessibilityState} accessibilityValue={accessibilityValue} accessibilityActions={this.props.accessibilityActions} onAccessibilityAction={this.props.onAccessibilityAction} diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js index bd30c9552337..afa771dbb0b8 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js @@ -324,6 +324,7 @@ class TouchableNativeFeedback extends React.Component< accessibilityLanguage: this.props.accessibilityLanguage, accessibilityLabel: accessibilityLabel, accessibilityRole: this.props.accessibilityRole, + role: this.props.role, accessibilityState: _accessibilityState, accessibilityActions: this.props.accessibilityActions, onAccessibilityAction: this.props.onAccessibilityAction, diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.js b/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.js index 6f86c40f369d..67ea871f5535 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.js @@ -227,6 +227,7 @@ class TouchableOpacity extends React.Component< accessibilityHint={this.props.accessibilityHint} accessibilityLanguage={this.props.accessibilityLanguage} accessibilityRole={this.props.accessibilityRole} + role={this.props.role} accessibilityState={_accessibilityState} accessibilityActions={this.props.accessibilityActions} onAccessibilityAction={this.props.onAccessibilityAction} diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js index 92c4ba114b7f..4898c95ae225 100755 --- a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -150,6 +150,7 @@ const PASSTHROUGH_PROPS = [ 'onBlur', 'onFocus', 'onLayout', + 'role', 'testID', ] as const; @@ -281,6 +282,11 @@ export default function TouchableWithoutFeedback( } } + const accessibilityLabel = props['aria-label'] ?? props.accessibilityLabel; + if (accessibilityLabel !== undefined) { + elementProps.accessibilityLabel = accessibilityLabel; + } + // $FlowFixMe[incompatible-type] return cloneElement(element, elementProps, ...children); } diff --git a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js index 5993615f245f..b0f897fa7941 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js @@ -51,6 +51,85 @@ describe('', () => { accessibilityPropsSuite(ComponentWithAccessibilityProps); + describe('aria state props', () => { + it('maps aria-checked to accessibilityState', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + + it('maps aria-busy, aria-expanded and aria-selected to accessibilityState', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + + it('maps aria-disabled to accessibilityState', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + + it('gives aria-checked precedence over accessibilityState', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + }); + describe('style', () => { it('applies style props', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableNativeFeedback-itest.js b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableNativeFeedback-itest.js index e8b620f64fcb..1f966a9affa5 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableNativeFeedback-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableNativeFeedback-itest.js @@ -10,6 +10,9 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; +import type {AccessibilityProps} from 'react-native'; + +import {rolePropSuite} from '../../../../src/private/__tests__/utilities/accessibilityPropsSuite'; import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {Text, TouchableNativeFeedback, View} from 'react-native'; @@ -19,6 +22,16 @@ describe('', () => { expect(TouchableNativeFeedback.displayName).toBe('TouchableNativeFeedback'); }); + component ComponentWithAccessibilityProps(...props: AccessibilityProps) { + return ( + + Touchable + + ); + } + + rolePropSuite(ComponentWithAccessibilityProps); + describe('rendering', () => { it('renders its child as pressable', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js index f01cd2d7326e..4e674f859f48 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js @@ -12,7 +12,9 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {AccessibilityProps} from 'react-native'; -import accessibilityPropsSuite from '../../../../src/private/__tests__/utilities/accessibilityPropsSuite'; +import accessibilityPropsSuite, { + rolePropSuite, +} from '../../../../src/private/__tests__/utilities/accessibilityPropsSuite'; import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; import * as React from 'react'; @@ -47,6 +49,59 @@ describe('', () => { } accessibilityPropsSuite(ComponentWithAccessibilityProps); + rolePropSuite(ComponentWithAccessibilityProps); + + describe('aria-label', () => { + it('is mapped to accessibilityLabel', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), + ).toEqual(); + }); + + it('takes precedence over accessibilityLabel', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), + ).toEqual(); + }); + + it("keeps the child's own label when neither is set", () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), + ).toEqual(); + }); + }); describe('disabled', () => { it('sets accessibilityState disabled to true', () => { From a7add41dc918f3d955b9b75837f4693888b16f43 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Thu, 5 Nov 2026 02:46:13 -0800 Subject: [PATCH 2/2] Reuse TouchableHighlight accessibility state without ARIA overrides Summary: Use the supplied `accessibilityState` as the default and only build a new state object when an ARIA state prop overrides it. This avoids reconstructing a sparse state object with extra undefined fields when no ARIA override is supplied. ARIA precedence and the explicit `disabled` override are unchanged. Refresh the affected snapshots and assert that the button keeps its empty accessibility state. Changelog: [Internal] Differential Revision: D129885143 --- .../Libraries/Components/Touchable/TouchableHighlight.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js index 854c9adf17ac..846f3d39559a 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js @@ -231,9 +231,8 @@ class TouchableHighlightImpl extends React.Component< 'aria-selected': ariaSelected, } = this.props; - let _accessibilityState: ?AccessibilityState; + let _accessibilityState: ?AccessibilityState = accessibilityState; if ( - accessibilityState != null || ariaBusy != null || ariaChecked != null || ariaDisabled != null ||