From 0c008070adc5b1c42d3cb8e898ce5637f793b25f Mon Sep 17 00:00:00 2001 From: Dmitry Zhigulin <104712148+faciledictu@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:06:08 +0000 Subject: [PATCH] fix: map aria-* props in Switch, ScrollView and ActivityIndicator These components render a native component directly instead of a View, so the aria-*, id and tabIndex props reach the native side unconverted and are dropped. Move the conversion View does into a helper and use it in the three components. --- .../ActivityIndicator/ActivityIndicator.js | 3 +- .../__tests__/ActivityIndicator-itest.js | 11 ++ .../Components/ScrollView/ScrollView.js | 3 +- .../ScrollView/__tests__/ScrollView-itest.js | 32 ++++++ .../Libraries/Components/Switch/Switch.js | 3 +- .../Switch/__tests__/Switch-itest.js | 42 +++++++ .../components/view/resolveAriaProps.js | 108 ++++++++++++++++++ 7 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/src/private/components/view/resolveAriaProps.js diff --git a/packages/react-native/Libraries/Components/ActivityIndicator/ActivityIndicator.js b/packages/react-native/Libraries/Components/ActivityIndicator/ActivityIndicator.js index e73577e68c0b..acbd6fa4a845 100644 --- a/packages/react-native/Libraries/Components/ActivityIndicator/ActivityIndicator.js +++ b/packages/react-native/Libraries/Components/ActivityIndicator/ActivityIndicator.js @@ -13,6 +13,7 @@ import type {HostInstance} from '../../../src/private/types/HostInstance'; import type {ViewProps} from '../View/ViewPropTypes'; +import resolveAriaProps from '../../../src/private/components/view/resolveAriaProps'; import StyleSheet, {type ColorValue} from '../../StyleSheet/StyleSheet'; import Platform from '../../Utilities/Platform'; import View from '../View/View'; @@ -142,7 +143,7 @@ const ActivityIndicator: component( animating, color, hidesWhenStopped, - ...restProps, + ...resolveAriaProps(restProps), ref: forwardedRef, style: sizeStyle, size: sizeProp, diff --git a/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js b/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js index 51636fc49de2..7b7897545dc2 100644 --- a/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js +++ b/packages/react-native/Libraries/Components/ActivityIndicator/__tests__/ActivityIndicator-itest.js @@ -55,4 +55,15 @@ describe('ActivityIndicator', () => { ), ).toEqual(); }); + + it('maps aria-label to accessibilityLabel', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), + ).toEqual(); + }); }); diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 55ebda75d1a9..ecea86c7dedc 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -31,6 +31,7 @@ import { VScrollContentViewNativeComponent, VScrollViewNativeComponent, } from '../../../src/private/components/scrollview/VScrollViewNativeComponents'; +import resolveAriaProps from '../../../src/private/components/view/resolveAriaProps'; import AnimatedImplementation from '../../Animated/AnimatedImplementation'; import FrameRateLogger from '../../Interaction/FrameRateLogger'; import {findNodeHandle} from '../../ReactNative/RendererProxy'; @@ -1861,7 +1862,7 @@ class ScrollView extends React.Component { const {experimental_endDraggingSensitivityMultiplier, ...otherProps} = this.props; const props = { - ...otherProps, + ...resolveAriaProps(otherProps), alwaysBounceHorizontal, alwaysBounceVertical, style: StyleSheet.compose(baseStyle, this.props.style), diff --git a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-itest.js b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-itest.js index 04135f43326f..e85a81561a29 100644 --- a/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-itest.js +++ b/packages/react-native/Libraries/Components/ScrollView/__tests__/ScrollView-itest.js @@ -71,6 +71,38 @@ describe('', () => { }); }); + describe('web-style accessibility props', () => { + it('maps aria-label to accessibilityLabel', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), + ).toEqual( + + + , + ); + }); + + it('maps id to nativeID', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect(root.getRenderedOutput({props: ['nativeID']}).toJSX()).toEqual( + + + , + ); + }); + }); + describe('ref', () => { it('receives an instance or null', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/Libraries/Components/Switch/Switch.js b/packages/react-native/Libraries/Components/Switch/Switch.js index bf284ba57789..5469ce0e9d1d 100644 --- a/packages/react-native/Libraries/Components/Switch/Switch.js +++ b/packages/react-native/Libraries/Components/Switch/Switch.js @@ -14,6 +14,7 @@ import type {NativeSyntheticEvent} from '../../Types/CoreEventTypes'; import type {AccessibilityState} from '../View/ViewAccessibility'; import type {ViewProps} from '../View/ViewPropTypes'; +import resolveAriaProps from '../../../src/private/components/view/resolveAriaProps'; import StyleSheet from '../../StyleSheet/StyleSheet'; import Platform from '../../Utilities/Platform'; import useMergeRefs from '../../Utilities/useMergeRefs'; @@ -186,7 +187,7 @@ const Switch: component( trackColor, value, ...restProps - } = props; + } = resolveAriaProps(props); const trackColorForFalse = trackColor?.false; const trackColorForTrue = trackColor?.true; diff --git a/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js b/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js index a97753e4b268..cd1c5fce3242 100644 --- a/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js +++ b/packages/react-native/Libraries/Components/Switch/__tests__/Switch-itest.js @@ -87,4 +87,46 @@ describe('Switch', () => { expect(onValueChange).toHaveBeenCalledWith(true); expect(onChange).toHaveBeenCalled(); }); + + describe('web-style accessibility props', () => { + it('maps aria-label to accessibilityLabel', () => { + const root = render(); + + expect( + root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), + ).toEqual(); + }); + + it('gives aria-label precedence over accessibilityLabel', () => { + const root = render( + , + ); + + expect( + root.getRenderedOutput({props: ['accessibilityLabel']}).toJSX(), + ).toEqual(); + }); + + it('maps aria-disabled to accessibilityState', () => { + const root = render(); + + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + + it('maps id to nativeID', () => { + const root = render(); + + expect(root.getRenderedOutput({props: ['nativeID']}).toJSX()).toEqual( + , + ); + }); + }); }); diff --git a/packages/react-native/src/private/components/view/resolveAriaProps.js b/packages/react-native/src/private/components/view/resolveAriaProps.js new file mode 100644 index 000000000000..69295416cda2 --- /dev/null +++ b/packages/react-native/src/private/components/view/resolveAriaProps.js @@ -0,0 +1,108 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ViewProps} from '../../../../Libraries/Components/View/ViewPropTypes'; + +/** + * Converts the web-style accessibility props (`aria-*`, `id`, `tabIndex`) to + * the props the native side reads, the same way `View` does. For components + * that render a native component directly instead of a `View`: without it, + * the native side drops these props. + */ +export default function resolveAriaProps( + props: T, +): T { + const { + accessibilityState, + accessibilityValue, + 'aria-busy': ariaBusy, + 'aria-checked': ariaChecked, + 'aria-disabled': ariaDisabled, + 'aria-expanded': ariaExpanded, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-live': ariaLive, + 'aria-selected': ariaSelected, + 'aria-valuemax': ariaValueMax, + 'aria-valuemin': ariaValueMin, + 'aria-valuenow': ariaValueNow, + 'aria-valuetext': ariaValueText, + id, + tabIndex, + ...otherProps + } = props; + + const resolvedProps = otherProps as {...ViewProps, ...}; + + const parsedAriaLabelledBy = ariaLabelledBy?.split(/\s*,\s*/g); + if (parsedAriaLabelledBy !== undefined) { + resolvedProps.accessibilityLabelledBy = parsedAriaLabelledBy; + } + + if (ariaLabel !== undefined) { + resolvedProps.accessibilityLabel = ariaLabel; + } + + if (ariaLive !== undefined) { + resolvedProps.accessibilityLiveRegion = + ariaLive === 'off' ? 'none' : ariaLive; + } + + if (ariaHidden !== undefined) { + resolvedProps.accessibilityElementsHidden = ariaHidden; + if (ariaHidden === true) { + resolvedProps.importantForAccessibility = 'no-hide-descendants'; + } + } + + if (id !== undefined) { + resolvedProps.nativeID = id; + } + + if (tabIndex !== undefined) { + resolvedProps.focusable = !tabIndex; + } + + if ( + accessibilityState != null || + ariaBusy != null || + ariaChecked != null || + ariaDisabled != null || + ariaExpanded != null || + ariaSelected != null + ) { + resolvedProps.accessibilityState = { + busy: ariaBusy ?? accessibilityState?.busy, + checked: ariaChecked ?? accessibilityState?.checked, + disabled: ariaDisabled ?? accessibilityState?.disabled, + expanded: ariaExpanded ?? accessibilityState?.expanded, + selected: ariaSelected ?? accessibilityState?.selected, + }; + } + + if ( + accessibilityValue != null || + ariaValueMax != null || + ariaValueMin != null || + ariaValueNow != null || + ariaValueText != null + ) { + resolvedProps.accessibilityValue = { + max: ariaValueMax ?? accessibilityValue?.max, + min: ariaValueMin ?? accessibilityValue?.min, + now: ariaValueNow ?? accessibilityValue?.now, + text: ariaValueText ?? accessibilityValue?.text, + }; + } + + // $FlowFixMe[incompatible-type] The aria-* props are replaced, the rest of T is kept + return resolvedProps; +}