From fe342ac1a214d32af4f12f3b9436bd09342beba5 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Fri, 23 Oct 2026 09:36:03 -0700 Subject: [PATCH 1/6] Avoid synthesising accessibilityState in pressable wrappers (#59399) Summary: Pull Request resolved: https://github.com/react/react-native/pull/59399 Only construct accessibility state in Pressable and Touchable wrappers when a state object, ARIA state or disabled value was supplied. Preserve explicit empty state objects, false and mixed values, and custom-child state when wrapper inputs are absent. Keep View normalisation unchanged. Changelog: [General][Fixed] - Avoid manufacturing empty accessibility state objects in pressable wrappers. Differential Revision: D127141096 --- .../Components/Pressable/Pressable.js | 39 +-- .../Pressable/__tests__/Pressable-itest.js | 152 +++++++++-- .../Touchable/TouchableNativeFeedback.js | 149 ++++++----- .../Components/Touchable/TouchableOpacity.js | 134 +++++----- .../Touchable/TouchableWithoutFeedback.js | 36 +-- .../TouchableNativeFeedback-itest.js | 140 +++++++++- .../__tests__/TouchableOpacity-itest.js | 131 ++++++++++ .../TouchableWithoutFeedback-itest.js | 243 ++++++++++++++++++ .../Components/View/__tests__/View-itest.js | 96 ++++++- 9 files changed, 924 insertions(+), 196 deletions(-) diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.js b/packages/react-native/Libraries/Components/Pressable/Pressable.js index 9523947d6159..c0e17432953d 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.js +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.js @@ -235,7 +235,7 @@ function Pressable({ style, testOnly_pressed, unstable_pressDelay, - ...restProps + ...otherProps } = props; const viewRef = useRef(null); @@ -248,17 +248,6 @@ function Pressable({ const shouldUpdatePressed = typeof children === 'function' || typeof style === 'function'; - let _accessibilityState = { - busy: ariaBusy ?? accessibilityState?.busy, - checked: ariaChecked ?? accessibilityState?.checked, - disabled: ariaDisabled ?? accessibilityState?.disabled, - expanded: ariaExpanded ?? accessibilityState?.expanded, - selected: ariaSelected ?? accessibilityState?.selected, - }; - - _accessibilityState = - disabled != null ? {..._accessibilityState, disabled} : _accessibilityState; - const accessibilityValue = { max: props['aria-valuemax'] ?? props.accessibilityValue?.max, min: props['aria-valuemin'] ?? props.accessibilityValue?.min, @@ -270,19 +259,35 @@ function Pressable({ ariaLive === 'off' ? 'none' : (ariaLive ?? props.accessibilityLiveRegion); const accessibilityLabel = ariaLabel ?? props.accessibilityLabel; - const restPropsWithDefaults: React.ComponentProps = { - ...restProps, + const restProps: {...React.ComponentProps} = { + ...otherProps, ...android_rippleConfig?.viewProps, accessible: accessible !== false, accessibilityViewIsModal: - restProps['aria-modal'] ?? restProps.accessibilityViewIsModal, + otherProps['aria-modal'] ?? otherProps.accessibilityViewIsModal, accessibilityLiveRegion, accessibilityLabel, - accessibilityState: _accessibilityState, focusable: focusable !== false, accessibilityValue, hitSlop, }; + if ( + accessibilityState != null || + ariaBusy != null || + ariaChecked != null || + ariaDisabled != null || + ariaExpanded != null || + ariaSelected != null || + disabled != null + ) { + restProps.accessibilityState = { + busy: ariaBusy ?? accessibilityState?.busy, + checked: ariaChecked ?? accessibilityState?.checked, + disabled: disabled ?? ariaDisabled ?? accessibilityState?.disabled, + expanded: ariaExpanded ?? accessibilityState?.expanded, + selected: ariaSelected ?? accessibilityState?.selected, + }; + } const config = useMemo( () => ({ @@ -357,7 +362,7 @@ function Pressable({ return ( ', () => { expect(root.getRenderedOutput().toJSX()).toEqual( ', () => { expect(root.getRenderedOutput().toJSX()).toEqual( , ); @@ -188,6 +186,137 @@ describe('', () => { }); }); + describe('accessibilityState', () => { + const rawFalseInputs: ReadonlyArray< + React.ComponentProps, + > = [ + {'aria-busy': false}, + {'aria-checked': false}, + {'aria-disabled': false}, + {'aria-expanded': false}, + {'aria-selected': false}, + {disabled: false}, + ]; + for (const props of rawFalseInputs) { + it(`creates state for ${Object.keys(props)[0]}=false without an accessibilityState input`, () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + expect( + Object.hasOwn( + root + .getRenderedOutput({props: ['accessibilityState']}) + .toJSONObject().props, + 'accessibilityState', + ), + ).toBe(true); + }); + } + const cases: ReadonlyArray<{ + name: string, + props: React.ComponentProps, + state: ?string, + }> = [ + {name: 'no input', props: {}, state: null}, + { + name: 'nullish raw inputs', + props: { + accessibilityState: null, + 'aria-busy': null, + 'aria-checked': null, + 'aria-disabled': null, + 'aria-expanded': null, + 'aria-selected': null, + disabled: null, + }, + state: null, + }, + { + name: 'explicit empty state', + props: {accessibilityState: {}}, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'explicit all-nullish state', + props: { + accessibilityState: { + busy: undefined, + checked: undefined, + disabled: undefined, + expanded: undefined, + selected: undefined, + }, + }, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'false aliases overriding state', + props: { + accessibilityState: { + busy: true, + checked: true, + disabled: true, + expanded: true, + selected: true, + }, + 'aria-busy': false, + 'aria-checked': false, + 'aria-disabled': false, + 'aria-expanded': false, + 'aria-selected': false, + }, + state: + '{disabled:false,selected:false,checked:Unchecked,busy:false,expanded:false}', + }, + { + name: 'mixed alias overriding state', + props: {accessibilityState: {checked: true}, 'aria-checked': 'mixed'}, + state: + '{disabled:false,selected:null,checked:Mixed,busy:false,expanded:null}', + }, + { + name: 'disabled false overriding alias and state', + props: { + disabled: false, + 'aria-disabled': true, + accessibilityState: {disabled: true}, + }, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'disabled true overriding alias and state', + props: { + disabled: true, + 'aria-disabled': false, + accessibilityState: {disabled: false}, + }, + state: + '{disabled:true,selected:null,checked:None,busy:false,expanded:null}', + }, + ]; + for (const {name, props, state} of cases) { + it(`preserves presence and values for ${name}`, () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + state == null ? ( + + ) : ( + + ), + ); + }); + } + }); + describe('android_ripple', () => { it('renders with a numeric color and alpha', () => { const root = Fantom.createRoot(); @@ -199,10 +328,7 @@ describe('', () => { }); expect(root.getRenderedOutput().toJSX()).toEqual( - , + , ); }); @@ -221,10 +347,7 @@ describe('', () => { }); expect(root.getRenderedOutput().toJSX()).toEqual( - , + , ); }); @@ -243,10 +366,7 @@ describe('', () => { }); expect(root.getRenderedOutput().toJSX()).toEqual( - , + , ); }); }); @@ -268,9 +388,7 @@ describe('', () => { expect(element.childNodes.length).toBe(1); expect(root.getRenderedOutput().toJSX()).toEqual( - + diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js index 92c4ba114b7f..5d78ea0e19ac 100755 --- a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -237,14 +237,6 @@ export default function TouchableWithoutFeedback( } } - let _accessibilityState = { - busy: props['aria-busy'] ?? props.accessibilityState?.busy, - checked: props['aria-checked'] ?? props.accessibilityState?.checked, - disabled: props['aria-disabled'] ?? props.accessibilityState?.disabled, - expanded: props['aria-expanded'] ?? props.accessibilityState?.expanded, - selected: props['aria-selected'] ?? props.accessibilityState?.selected, - }; - // BACKWARD-COMPATIBILITY: Focus and blur events were never supported before // adopting `Pressability`, so preserve that behavior. const {onBlur, onFocus, ...eventHandlersWithoutBlurAndFocus} = eventHandlers; @@ -252,13 +244,6 @@ export default function TouchableWithoutFeedback( const elementProps: {[string]: unknown, ...} = { ...eventHandlersWithoutBlurAndFocus, accessible: props.accessible !== false, - accessibilityState: - props.disabled != null - ? { - ..._accessibilityState, - disabled: props.disabled, - } - : _accessibilityState, focusable: props.focusable !== false && props.onPress !== undefined && @@ -275,6 +260,27 @@ export default function TouchableWithoutFeedback( nativeID: props.id ?? props.nativeID, }; + if ( + props.accessibilityState != null || + props['aria-busy'] != null || + props['aria-checked'] != null || + props['aria-disabled'] != null || + props['aria-expanded'] != null || + props['aria-selected'] != null || + props.disabled != null + ) { + elementProps.accessibilityState = { + busy: props['aria-busy'] ?? props.accessibilityState?.busy, + checked: props['aria-checked'] ?? props.accessibilityState?.checked, + disabled: + props.disabled ?? + props['aria-disabled'] ?? + props.accessibilityState?.disabled, + expanded: props['aria-expanded'] ?? props.accessibilityState?.expanded, + selected: props['aria-selected'] ?? props.accessibilityState?.selected, + }; + } + for (const prop of PASSTHROUGH_PROPS) { if (props[prop] !== undefined) { elementProps[prop] = props[prop]; 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..0cf0cb601a6a 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableNativeFeedback-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableNativeFeedback-itest.js @@ -52,14 +52,146 @@ describe('', () => { }); expect(root.getRenderedOutput().toJSX()).toEqual( - , + , ); }); }); + describe('accessibilityState', () => { + const rawFalseInputs: ReadonlyArray< + React.ComponentProps, + > = [ + {'aria-busy': false}, + {'aria-checked': false}, + {'aria-disabled': false}, + {'aria-expanded': false}, + {'aria-selected': false}, + {disabled: false}, + ]; + for (const props of rawFalseInputs) { + it(`creates state for ${Object.keys(props)[0]}=false without an accessibilityState input`, () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + + + , + ); + }); + expect( + Object.hasOwn( + root + .getRenderedOutput({props: ['accessibilityState']}) + .toJSONObject().props, + 'accessibilityState', + ), + ).toBe(true); + }); + } + const cases: ReadonlyArray<{ + name: string, + props: React.ComponentProps, + state: ?string, + }> = [ + {name: 'no input', props: {}, state: null}, + { + name: 'nullish raw inputs', + props: { + accessibilityState: null, + 'aria-busy': null, + 'aria-checked': null, + 'aria-disabled': null, + 'aria-expanded': null, + 'aria-selected': null, + disabled: null, + }, + state: null, + }, + { + name: 'explicit empty state', + props: {accessibilityState: {}}, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'explicit all-nullish state', + props: { + accessibilityState: { + busy: undefined, + checked: undefined, + disabled: undefined, + expanded: undefined, + selected: undefined, + }, + }, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'false aliases overriding state', + props: { + accessibilityState: { + busy: true, + checked: true, + disabled: true, + expanded: true, + selected: true, + }, + 'aria-busy': false, + 'aria-checked': false, + 'aria-disabled': false, + 'aria-expanded': false, + 'aria-selected': false, + }, + state: + '{disabled:false,selected:false,checked:Unchecked,busy:false,expanded:false}', + }, + { + name: 'mixed alias overriding state', + props: {accessibilityState: {checked: true}, 'aria-checked': 'mixed'}, + state: + '{disabled:false,selected:null,checked:Mixed,busy:false,expanded:null}', + }, + { + name: 'disabled false overriding alias and state', + props: { + disabled: false, + 'aria-disabled': true, + accessibilityState: {disabled: true}, + }, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'disabled true overriding alias and state', + props: { + disabled: true, + 'aria-disabled': false, + accessibilityState: {disabled: false}, + }, + state: + '{disabled:true,selected:null,checked:None,busy:false,expanded:null}', + }, + ]; + for (const {name, props, state} of cases) { + it(`preserves presence and values for ${name}`, () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + + + , + ); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + state == null ? : , + ); + }); + } + }); + describe('disabled', () => { it('sets accessibilityState disabled to true', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js index 2e7b6367e806..39dff12188fc 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js @@ -176,6 +176,137 @@ describe('', () => { }); }); + describe('accessibilityState', () => { + const rawFalseInputs: ReadonlyArray< + React.ComponentProps, + > = [ + {'aria-busy': false}, + {'aria-checked': false}, + {'aria-disabled': false}, + {'aria-expanded': false}, + {'aria-selected': false}, + {disabled: false}, + ]; + for (const props of rawFalseInputs) { + it(`creates state for ${Object.keys(props)[0]}=false without an accessibilityState input`, () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + expect( + Object.hasOwn( + root + .getRenderedOutput({props: ['accessibilityState']}) + .toJSONObject().props, + 'accessibilityState', + ), + ).toBe(true); + }); + } + const cases: ReadonlyArray<{ + name: string, + props: React.ComponentProps, + state: ?string, + }> = [ + {name: 'no input', props: {}, state: null}, + { + name: 'nullish raw inputs', + props: { + accessibilityState: null, + 'aria-busy': null, + 'aria-checked': null, + 'aria-disabled': null, + 'aria-expanded': null, + 'aria-selected': null, + disabled: null, + }, + state: null, + }, + { + name: 'explicit empty state', + props: {accessibilityState: {}}, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'explicit all-nullish state', + props: { + accessibilityState: { + busy: undefined, + checked: undefined, + disabled: undefined, + expanded: undefined, + selected: undefined, + }, + }, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'false aliases overriding state', + props: { + accessibilityState: { + busy: true, + checked: true, + disabled: true, + expanded: true, + selected: true, + }, + 'aria-busy': false, + 'aria-checked': false, + 'aria-disabled': false, + 'aria-expanded': false, + 'aria-selected': false, + }, + state: + '{disabled:false,selected:false,checked:Unchecked,busy:false,expanded:false}', + }, + { + name: 'mixed alias overriding state', + props: {accessibilityState: {checked: true}, 'aria-checked': 'mixed'}, + state: + '{disabled:false,selected:null,checked:Mixed,busy:false,expanded:null}', + }, + { + name: 'disabled false overriding alias and state', + props: { + disabled: false, + 'aria-disabled': true, + accessibilityState: {disabled: true}, + }, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'disabled true overriding alias and state', + props: { + disabled: true, + 'aria-disabled': false, + accessibilityState: {disabled: false}, + }, + state: + '{disabled:true,selected:null,checked:None,busy:false,expanded:null}', + }, + ]; + for (const {name, props, state} of cases) { + it(`preserves presence and values for ${name}`, () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + state == null ? ( + + ) : ( + + ), + ); + }); + } + }); + describe('children', () => { it('renders children inside the touchable', () => { 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..ee4eca4ed28c 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableWithoutFeedback-itest.js @@ -48,6 +48,249 @@ describe('', () => { accessibilityPropsSuite(ComponentWithAccessibilityProps); + describe('accessibilityState', () => { + const rawFalseInputs: ReadonlyArray< + React.ComponentProps, + > = [ + {'aria-busy': false}, + {'aria-checked': false}, + {'aria-disabled': false}, + {'aria-expanded': false}, + {'aria-selected': false}, + {disabled: false}, + ]; + for (const props of rawFalseInputs) { + it(`creates state for ${Object.keys(props)[0]}=false without an accessibilityState input`, () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + + Touchable + , + ); + }); + expect( + Object.hasOwn( + root + .getRenderedOutput({props: ['accessibilityState']}) + .toJSONObject().props, + 'accessibilityState', + ), + ).toBe(true); + }); + } + const cases: ReadonlyArray<{ + name: string, + props: React.ComponentProps, + state: ?string, + }> = [ + {name: 'no input', props: {}, state: null}, + { + name: 'nullish raw inputs', + props: { + accessibilityState: null, + 'aria-busy': null, + 'aria-checked': null, + 'aria-disabled': null, + 'aria-expanded': null, + 'aria-selected': null, + disabled: null, + }, + state: null, + }, + { + name: 'explicit empty state', + props: {accessibilityState: {}}, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'explicit all-nullish state', + props: { + accessibilityState: { + busy: undefined, + checked: undefined, + disabled: undefined, + expanded: undefined, + selected: undefined, + }, + }, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'false aliases overriding state', + props: { + accessibilityState: { + busy: true, + checked: true, + disabled: true, + expanded: true, + selected: true, + }, + 'aria-busy': false, + 'aria-checked': false, + 'aria-disabled': false, + 'aria-expanded': false, + 'aria-selected': false, + }, + state: + '{disabled:false,selected:false,checked:Unchecked,busy:false,expanded:false}', + }, + { + name: 'mixed alias overriding state', + props: {accessibilityState: {checked: true}, 'aria-checked': 'mixed'}, + state: + '{disabled:false,selected:null,checked:Mixed,busy:false,expanded:null}', + }, + { + name: 'disabled false overriding alias and state', + props: { + disabled: false, + 'aria-disabled': true, + accessibilityState: {disabled: true}, + }, + state: + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + }, + { + name: 'disabled true overriding alias and state', + props: { + disabled: true, + 'aria-disabled': false, + accessibilityState: {disabled: false}, + }, + state: + '{disabled:true,selected:null,checked:None,busy:false,expanded:null}', + }, + ]; + for (const {name, props, state} of cases) { + it(`preserves presence and values for ${name}`, () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + + Touchable + , + ); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + state == null ? ( + Touchable + ) : ( + Touchable + ), + ); + }); + } + + it('omits the cloned state key and preserves a custom child state without input', () => { + const root = Fantom.createRoot(); + let capturedProps: React.ComponentProps | null = null; + component CaptureChild(...props: React.ComponentProps) { + capturedProps = props; + return Touchable; + } + Fantom.runTask(() => { + root.render( + + + , + ); + }); + expect( + Object.hasOwn(nullthrows(capturedProps), 'accessibilityState'), + ).toBe(false); + + const childState: NonNullable< + AccessibilityProps['accessibilityState'], + > = {checked: 'mixed', selected: false}; + Fantom.runTask(() => { + root.render( + + + , + ); + }); + expect(nullthrows(capturedProps).accessibilityState).toBe(childState); + }); + + it('preserves explicit empty and nullish state for a custom child before native parsing', () => { + const root = Fantom.createRoot(); + let capturedState: AccessibilityProps['accessibilityState']; + component CaptureChild(...props: AccessibilityProps) { + capturedState = props.accessibilityState; + return Touchable; + } + const states: ReadonlyArray< + NonNullable, + > = [ + {}, + { + busy: undefined, + checked: null, + disabled: undefined, + expanded: null, + selected: undefined, + }, + ]; + for (const accessibilityState of states) { + Fantom.runTask(() => { + root.render( + + + , + ); + }); + expect(capturedState).toEqual({ + busy: undefined, + checked: accessibilityState.checked, + disabled: undefined, + expanded: accessibilityState.expanded, + selected: undefined, + }); + } + }); + + it('maps state aliases locally for a custom child and preserves disabled precedence', () => { + const root = Fantom.createRoot(); + let capturedProps: React.ComponentProps | null = null; + component CaptureChild(...props: React.ComponentProps) { + capturedProps = props; + return Touchable; + } + Fantom.runTask(() => { + root.render( + + + , + ); + }); + expect(nullthrows(capturedProps).accessibilityState).toEqual({ + busy: false, + checked: 'mixed', + disabled: false, + expanded: false, + selected: false, + }); + }); + }); + describe('disabled', () => { it('sets accessibilityState disabled to true', () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-itest.js index a26f29766aed..d001d68bb481 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-itest.js @@ -12,7 +12,7 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; -import type {HostInstance} from 'react-native'; +import type {AccessibilityProps, HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; @@ -458,6 +458,100 @@ describe('', () => { }); describe('accessibility', () => { + it('omits accessibilityState when no state input is supplied', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual(); + }); + + it('accepts explicitly supplied empty and all-nullish accessibilityState', () => { + const root = Fantom.createRoot(); + const emptyStates: ReadonlyArray< + AccessibilityProps['accessibilityState'], + > = [ + {}, + { + busy: undefined, + checked: undefined, + disabled: undefined, + expanded: undefined, + selected: undefined, + }, + ]; + for (const accessibilityState of emptyStates) { + Fantom.runTask(() => { + root.render( + , + ); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + } + }); + + it('keeps false state fields and gives aliases precedence', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + + it('keeps accessibilityState present when replaced with an explicit empty object', () => { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + , + ); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + Fantom.runTask(() => { + root.render(); + }); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + describe('accessibilityActions', () => { it('is propagated to the mounting layer', () => { const root = Fantom.createRoot(); From 3c842b518cddb88b22cfb3cc3a8ae138ccc687a4 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Fri, 23 Oct 2026 09:36:03 -0700 Subject: [PATCH 2/6] Exercise View normalisation in the Jest preset (#59398) Summary: Pull Request resolved: https://github.com/react/react-native/pull/59398 Mock only the native View boundary so Jest exercises the real `View` prop normalisation. Preserve native ref methods and migrate dependent tests to observe native host state rather than implementation details. Changelog: [Internal] Differential Revision: D127141935 --- packages/jest-preset/jest/mocks/View.js | 27 ------------------- .../jest/mocks/ViewNativeComponent.js | 11 ++++++++ packages/jest-preset/jest/setup.js | 2 -- 3 files changed, 11 insertions(+), 29 deletions(-) delete mode 100644 packages/jest-preset/jest/mocks/View.js diff --git a/packages/jest-preset/jest/mocks/View.js b/packages/jest-preset/jest/mocks/View.js deleted file mode 100644 index fce3ca9fca0c..000000000000 --- a/packages/jest-preset/jest/mocks/View.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 typeof * as TmockComponent from '../mockComponent'; -import typeof * as TMockNativeMethods from '../MockNativeMethods'; -import typeof {View as TView} from 'react-native'; - -const mockComponent = - jest.requireActual('../mockComponent').default; -const MockNativeMethods = jest.requireActual( - '../MockNativeMethods', -).default; - -const View = mockComponent( - 'react-native/Libraries/Components/View/View', - MockNativeMethods, // instanceMethods - true, // isESModule -) as TView; - -export default View; diff --git a/packages/jest-preset/jest/mocks/ViewNativeComponent.js b/packages/jest-preset/jest/mocks/ViewNativeComponent.js index 4f6ca6f36eae..5907ea549ada 100644 --- a/packages/jest-preset/jest/mocks/ViewNativeComponent.js +++ b/packages/jest-preset/jest/mocks/ViewNativeComponent.js @@ -10,10 +10,21 @@ import type {ViewProps} from 'react-native'; +import MockNativeMethods from '../MockNativeMethods'; import * as React from 'react'; import {createElement} from 'react'; export default class View extends React.Component { + measure: typeof MockNativeMethods.measure = MockNativeMethods.measure; + measureInWindow: typeof MockNativeMethods.measureInWindow = + MockNativeMethods.measureInWindow; + measureLayout: typeof MockNativeMethods.measureLayout = + MockNativeMethods.measureLayout; + setNativeProps: typeof MockNativeMethods.setNativeProps = + MockNativeMethods.setNativeProps; + focus: typeof MockNativeMethods.focus = MockNativeMethods.focus; + blur: typeof MockNativeMethods.blur = MockNativeMethods.blur; + render(): React.Node { // $FlowFixMe[not-a-function] return createElement('View', this.props, this.props.children); diff --git a/packages/jest-preset/jest/setup.js b/packages/jest-preset/jest/setup.js index 87049860fe3e..c3d63b11e7f9 100644 --- a/packages/jest-preset/jest/setup.js +++ b/packages/jest-preset/jest/setup.js @@ -137,8 +137,6 @@ mock( 'm#react-native/Libraries/Components/TextInput/TextInput', 'm#./mocks/TextInput', ); -// $FlowFixMe[cannot-resolve-module] -mock('m#react-native/Libraries/Components/View/View', 'm#./mocks/View'); mock( // $FlowFixMe[cannot-resolve-module] 'm#react-native/Libraries/Components/View/ViewNativeComponent', From 9f86d40d6e2901bd5b6b912fc673d9ee23eb1473 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Fri, 23 Oct 2026 09:37:10 -0700 Subject: [PATCH 3/6] Delegate Pressable accessibility normalisation to View Summary: Forward accessibility props to `View` rather than building duplicate accessibility state and value objects. Preserve explicit disabled precedence and existing press behaviour. Changelog: [General][Fixed] - Avoid synthesising empty accessibility props in Pressable. Differential Revision: D127143129 --- .../Components/Pressable/Pressable.js | 70 +++++------------ .../Pressable/__tests__/Pressable-itest.js | 75 +++++++++++++++++++ 2 files changed, 92 insertions(+), 53 deletions(-) diff --git a/packages/react-native/Libraries/Components/Pressable/Pressable.js b/packages/react-native/Libraries/Components/Pressable/Pressable.js index c0e17432953d..f3a5fe0f73aa 100644 --- a/packages/react-native/Libraries/Components/Pressable/Pressable.js +++ b/packages/react-native/Libraries/Components/Pressable/Pressable.js @@ -203,16 +203,8 @@ function Pressable({ }): React.Node { const { accessible, - accessibilityState, - 'aria-live': ariaLive, android_disableSound, android_ripple, - 'aria-busy': ariaBusy, - 'aria-checked': ariaChecked, - 'aria-disabled': ariaDisabled, - 'aria-expanded': ariaExpanded, - 'aria-label': ariaLabel, - 'aria-selected': ariaSelected, blockNativeResponder, cancelable, children, @@ -235,7 +227,7 @@ function Pressable({ style, testOnly_pressed, unstable_pressDelay, - ...otherProps + ...restProps } = props; const viewRef = useRef(null); @@ -248,48 +240,7 @@ function Pressable({ const shouldUpdatePressed = typeof children === 'function' || typeof style === 'function'; - const accessibilityValue = { - max: props['aria-valuemax'] ?? props.accessibilityValue?.max, - min: props['aria-valuemin'] ?? props.accessibilityValue?.min, - now: props['aria-valuenow'] ?? props.accessibilityValue?.now, - text: props['aria-valuetext'] ?? props.accessibilityValue?.text, - }; - - const accessibilityLiveRegion = - ariaLive === 'off' ? 'none' : (ariaLive ?? props.accessibilityLiveRegion); - - const accessibilityLabel = ariaLabel ?? props.accessibilityLabel; - const restProps: {...React.ComponentProps} = { - ...otherProps, - ...android_rippleConfig?.viewProps, - accessible: accessible !== false, - accessibilityViewIsModal: - otherProps['aria-modal'] ?? otherProps.accessibilityViewIsModal, - accessibilityLiveRegion, - accessibilityLabel, - focusable: focusable !== false, - accessibilityValue, - hitSlop, - }; - if ( - accessibilityState != null || - ariaBusy != null || - ariaChecked != null || - ariaDisabled != null || - ariaExpanded != null || - ariaSelected != null || - disabled != null - ) { - restProps.accessibilityState = { - busy: ariaBusy ?? accessibilityState?.busy, - checked: ariaChecked ?? accessibilityState?.checked, - disabled: disabled ?? ariaDisabled ?? accessibilityState?.disabled, - expanded: ariaExpanded ?? accessibilityState?.expanded, - selected: ariaSelected ?? accessibilityState?.selected, - }; - } - - const config = useMemo( + const pressabilityConfig = useMemo( () => ({ cancelable, disabled, @@ -358,11 +309,24 @@ function Pressable({ ], ); - const eventHandlers = usePressability(config); + const eventHandlers = usePressability(pressabilityConfig); + + const restPropsWithDefaults: {...React.ComponentProps} = { + ...restProps, + ...android_rippleConfig?.viewProps, + accessible: accessible !== false, + focusable: focusable !== false, + hitSlop, + }; + + if (pressabilityConfig.disabled != null) { + // Expose Pressability's resolved disabled state to accessibility through View. + restPropsWithDefaults['aria-disabled'] = pressabilityConfig.disabled; + } return ( ', () => { describe('props', () => { + it('lets View resolve accessibility aliases and their precedence', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect( + root + .getRenderedOutput({ + props: ['accessibilityLabel', 'accessibilityState'], + }) + .toJSONObject().props, + ).toEqual({ + accessibilityLabel: 'ARIA label', + accessibilityState: + '{disabled:false,selected:false,checked:Unchecked,busy:false,expanded:false}', + }); + }); + describe('style', () => { it('can be set with ViewStyle', () => { const root = Fantom.createRoot(); @@ -99,6 +134,46 @@ describe('', () => { }); }); + it('forwards accessibility-only disabled state without blocking presses', () => { + const root = Fantom.createRoot(); + const elementRef = createRef(); + const onPress = jest.fn(); + let expectedPresses = 0; + + const disabledProps: ReadonlyArray = [ + {'aria-disabled': true}, + {accessibilityState: {disabled: true}}, + ]; + for (const props of disabledProps) { + Fantom.runTask(() => { + root.render( + , + ); + }); + Fantom.dispatchNativeEvent(nullthrows(elementRef.current), 'click'); + expectedPresses += 1; + expect(onPress).toHaveBeenCalledTimes(expectedPresses); + } + + Fantom.runTask(() => { + root.render( + , + ); + }); + Fantom.dispatchNativeEvent(nullthrows(elementRef.current), 'click'); + expect(onPress).toHaveBeenCalledTimes(3); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + describe('disabled', () => { it('cannot be pressed', () => { const elementRef = createRef(); From 17e6a065bb247ad5ea767402f59979b46201c97c Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Fri, 23 Oct 2026 09:37:10 -0700 Subject: [PATCH 4/6] Delegate TouchableHighlight accessibility to View (#59402) Summary: Pull Request resolved: https://github.com/react/react-native/pull/59402 Let `View` normalise accessibility aliases and forward remaining view props, retaining TouchableHighlight defaults, press behaviour and underlay styling. Changelog: [General][Fixed] - Forward view and accessibility props consistently in TouchableHighlight. Differential Revision: D127143599 --- .../Touchable/TouchableHighlight.js | 104 +++++++----------- .../__tests__/TouchableHighlight-itest.js | 78 +++++++++++++ 2 files changed, 116 insertions(+), 66 deletions(-) diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js index e8a01c46ff2c..e769ab27a027 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableHighlight.js @@ -10,7 +10,6 @@ import type {HostInstance} from '../../../src/private/types/HostInstance'; import type {ColorValue} from '../../StyleSheet/StyleSheet'; -import type {AccessibilityState} from '../View/ViewAccessibility'; import type {TouchableWithoutFeedbackProps} from './TouchableWithoutFeedback'; import View from '../../Components/View/View'; @@ -97,10 +96,7 @@ class TouchableHighlightImpl extends React.Component< _createPressabilityConfig(): PressabilityConfig { return { cancelable: !this.props.rejectResponderTermination, - disabled: - this.props.disabled != null - ? this.props.disabled - : this.props.accessibilityState?.disabled, + disabled: this.props.disabled ?? this.props.accessibilityState?.disabled, hitSlop: this.props.hitSlop, delayLongPress: this.props.delayLongPress, delayPressIn: this.props.delayPressIn, @@ -222,70 +218,46 @@ 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 accessibilityValue = { - max: this.props['aria-valuemax'] ?? this.props.accessibilityValue?.max, - min: this.props['aria-valuemin'] ?? this.props.accessibilityValue?.min, - now: this.props['aria-valuenow'] ?? this.props.accessibilityValue?.now, - text: this.props['aria-valuetext'] ?? this.props.accessibilityValue?.text, - }; - - const accessibilityLiveRegion = - this.props['aria-live'] === 'off' - ? 'none' - : (this.props['aria-live'] ?? this.props.accessibilityLiveRegion); + const { + accessible, + activeOpacity, + children, + delayLongPress, + delayPressIn, + delayPressOut, + disabled, + focusable, + hostRef, + onBlur: _onBlur, + onFocus: _onFocus, + onHideUnderlay, + onLongPress, + onPress, + onPressIn, + onPressOut, + onShowUnderlay, + pressRetentionOffset, + rejectResponderTermination, + style, + testOnly_pressed, + touchSoundDisabled, + underlayColor, + ...restProps + } = this.props; + + const disabledState = disabled ?? this.props.accessibilityState?.disabled; + if (disabledState != null) { + // Expose Pressability's resolved disabled state to accessibility through View. + restProps['aria-disabled'] = disabledState; + } - const accessibilityLabel = - this.props['aria-label'] ?? this.props.accessibilityLabel; return ( {child.type === React.Fragment ? child 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..466f48dd03f8 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableHighlight-itest.js @@ -23,6 +23,42 @@ import {Text, TouchableHighlight, View} from 'react-native'; describe('', () => { describe('props', () => { + it('lets View resolve accessibility aliases and their precedence', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root + .getRenderedOutput({ + props: ['accessibilityLabel', 'accessibilityState'], + }) + .toJSONObject().props, + ).toEqual({ + accessibilityLabel: 'ARIA label', + accessibilityState: + '{disabled:false,selected:false,checked:Unchecked,busy:false,expanded:false}', + }); + }); + describe('rendering', () => { it('renders as a view with accessible="true"', () => { const root = Fantom.createRoot(); @@ -247,6 +283,48 @@ describe('', () => { }); }); + it('preserves aria-only interactions and lets disabled override state', () => { + const root = Fantom.createRoot(); + const elementRef = createRef(); + const onPress = jest.fn(); + + const disabledProps: ReadonlyArray = [ + {'aria-disabled': true}, + {accessibilityState: {disabled: true}}, + ]; + for (const props of disabledProps) { + Fantom.runTask(() => { + root.render( + + + , + ); + }); + Fantom.dispatchNativeEvent(nullthrows(elementRef.current), 'click'); + expect(onPress).toHaveBeenCalledTimes(1); + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + Fantom.dispatchNativeEvent(nullthrows(elementRef.current), 'click'); + expect(onPress).toHaveBeenCalledTimes(2); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSONObject() + .props.accessibilityState, + ).toBe( + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + ); + }); + describe('disabled', () => { it('cannot be pressed when disabled', () => { const elementRef = createRef(); From f0196ac2471188b244009c5dbc3bec57544fb684 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Fri, 23 Oct 2026 09:37:18 -0700 Subject: [PATCH 5/6] Delegate TouchableOpacity accessibility to View Summary: Forward remaining view props and delegate accessibility aliases to `View` while retaining opacity animation, press behaviour and disabled precedence. Changelog: [General][Fixed] - Forward view and accessibility props consistently in TouchableOpacity. Differential Revision: D127143976 --- .../Components/Touchable/TouchableOpacity.js | 105 ++++++------------ .../__tests__/TouchableOpacity-itest.js | 75 ++++++++++++- 2 files changed, 109 insertions(+), 71 deletions(-) diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.js b/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.js index e1e46bf178e6..3372ff9ef0ef 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableOpacity.js @@ -187,82 +187,47 @@ class TouchableOpacity extends React.Component< const {onBlur, onFocus, ...eventHandlersWithoutBlurAndFocus} = this.state.pressability.getEventHandlers(); - const restProps: {...ViewProps} = { - accessible: this.props.accessible !== false, - accessibilityLabel: - this.props['aria-label'] ?? this.props.accessibilityLabel, - accessibilityHint: this.props.accessibilityHint, - accessibilityLanguage: this.props.accessibilityLanguage, - accessibilityRole: this.props.accessibilityRole, - accessibilityActions: this.props.accessibilityActions, - onAccessibilityAction: this.props.onAccessibilityAction, - accessibilityValue: { - max: this.props['aria-valuemax'] ?? this.props.accessibilityValue?.max, - min: this.props['aria-valuemin'] ?? this.props.accessibilityValue?.min, - now: this.props['aria-valuenow'] ?? this.props.accessibilityValue?.now, - text: - this.props['aria-valuetext'] ?? this.props.accessibilityValue?.text, - }, - importantForAccessibility: - this.props['aria-hidden'] === true - ? 'no-hide-descendants' - : this.props.importantForAccessibility, - accessibilityViewIsModal: - this.props['aria-modal'] ?? this.props.accessibilityViewIsModal, - accessibilityLiveRegion: - this.props['aria-live'] === 'off' - ? 'none' - : (this.props['aria-live'] ?? this.props.accessibilityLiveRegion), - accessibilityElementsHidden: - this.props['aria-hidden'] ?? this.props.accessibilityElementsHidden, - nativeID: this.props.id ?? this.props.nativeID, - testID: this.props.testID, - onLayout: this.props.onLayout, - nextFocusDown: this.props.nextFocusDown, - nextFocusForward: this.props.nextFocusForward, - nextFocusLeft: this.props.nextFocusLeft, - nextFocusRight: this.props.nextFocusRight, - nextFocusUp: this.props.nextFocusUp, - hitSlop: this.props.hitSlop, - focusable: - this.props.focusable !== false && - this.props.onPress !== undefined && - !this.props.disabled, - }; - if ( - this.props.accessibilityState != null || - this.props['aria-busy'] != null || - this.props['aria-checked'] != null || - this.props['aria-disabled'] != null || - this.props['aria-expanded'] != null || - this.props['aria-selected'] != null || - this.props.disabled != null - ) { - restProps.accessibilityState = { - busy: this.props['aria-busy'] ?? this.props.accessibilityState?.busy, - checked: - this.props['aria-checked'] ?? this.props.accessibilityState?.checked, - disabled: - this.props.disabled ?? - this.props['aria-disabled'] ?? - this.props.accessibilityState?.disabled, - expanded: - this.props['aria-expanded'] ?? - this.props.accessibilityState?.expanded, - selected: - this.props['aria-selected'] ?? - this.props.accessibilityState?.selected, - }; + const { + accessible, + activeOpacity, + children, + delayLongPress, + delayPressIn, + delayPressOut, + disabled, + focusable, + hostRef, + onBlur: _onBlur, + onFocus: _onFocus, + onLongPress, + onPress, + onPressIn, + onPressOut, + pressRetentionOffset, + rejectResponderTermination, + style, + touchSoundDisabled, + ...restProps + } = this.props; + + const pressabilityConfig = this._createPressabilityConfig(); + if (pressabilityConfig.disabled != null) { + // Expose Pressability's resolved disabled state to accessibility through View. + restProps['aria-disabled'] = pressabilityConfig.disabled; } + const viewProps: ViewProps = restProps; + return ( - {this.props.children} + {children} {__DEV__ ? ( ) : null} diff --git a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js index 39dff12188fc..48c41af4285c 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableOpacity-itest.js @@ -11,7 +11,7 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; -import type {HostInstance} from 'react-native'; +import type {AccessibilityProps, HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; @@ -25,6 +25,41 @@ describe('', () => { }); describe('props', () => { + it('lets View resolve accessibility aliases and their precedence', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect( + root + .getRenderedOutput({ + props: ['accessibilityLabel', 'accessibilityState'], + }) + .toJSONObject().props, + ).toEqual({ + accessibilityLabel: 'ARIA label', + accessibilityState: + '{disabled:false,selected:false,checked:Unchecked,busy:false,expanded:false}', + }); + }); + describe('rendering', () => { it('renders as a view with accessible="true"', () => { const root = Fantom.createRoot(); @@ -122,6 +157,44 @@ describe('', () => { }); }); + it('uses inherited disabled state for presses and lets disabled override it', () => { + const root = Fantom.createRoot(); + const elementRef = createRef(); + const onPress = jest.fn(); + + const disabledProps: ReadonlyArray = [ + {'aria-disabled': true}, + {accessibilityState: {disabled: true}}, + ]; + for (const props of disabledProps) { + Fantom.runTask(() => { + root.render( + , + ); + }); + Fantom.dispatchNativeEvent(nullthrows(elementRef.current), 'click'); + expect(onPress).not.toHaveBeenCalled(); + } + + Fantom.runTask(() => { + root.render( + , + ); + }); + Fantom.dispatchNativeEvent(nullthrows(elementRef.current), 'click'); + expect(onPress).toHaveBeenCalledTimes(1); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual( + , + ); + }); + describe('disabled', () => { it('cannot be pressed when disabled', () => { const elementRef = createRef(); From 098c01b234ceb910d6c9021464c66b04fef1db44 Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Fri, 23 Oct 2026 09:44:54 -0700 Subject: [PATCH 6/6] Delegate TouchableNativeFeedback accessibility to its child (#59391) Summary: Pull Request resolved: https://github.com/react/react-native/pull/59391 Forward unconsumed view props to the native-feedback child and let `View` normalise accessibility aliases. Preserve child styling and retain legacy alias resolution for non-View children without synthesising empty state or value props. Changelog: [General][Fixed] - Forward accessibility props consistently in TouchableNativeFeedback while preserving child styles. Differential Revision: D126907218 --- .../Touchable/TouchableNativeFeedback.js | 185 +++++++++------- .../TouchableNativeFeedback-itest.js | 209 ++++++++++++++++++ 2 files changed, 319 insertions(+), 75 deletions(-) diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js index df556bc01330..03516855bd4d 100644 --- a/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableNativeFeedback.js @@ -11,7 +11,6 @@ import type {ProcessedColorValue} from '../../StyleSheet/processColor'; import type {ColorValue} from '../../StyleSheet/StyleSheet'; import type {GestureResponderEvent} from '../../Types/CoreEventTypes'; -import type {ViewProps} from '../View/ViewPropTypes'; import type {TouchableWithoutFeedbackProps} from './TouchableWithoutFeedback'; import View from '../../Components/View/View'; @@ -276,82 +275,118 @@ class TouchableNativeFeedback extends React.Component< const {onBlur, onFocus, ...eventHandlersWithoutBlurAndFocus} = this.state.pressability.getEventHandlers(); - const restProps: {...ViewProps} = { - ...eventHandlersWithoutBlurAndFocus, - ...getBackgroundProp( - this.props.background === undefined - ? TouchableNativeFeedback.SelectableBackground() - : this.props.background, - this.props.useForeground === true, - ), - accessible: this.props.accessible !== false, - accessibilityHint: this.props.accessibilityHint, - accessibilityLanguage: this.props.accessibilityLanguage, - accessibilityLabel: - this.props['aria-label'] ?? this.props.accessibilityLabel, - accessibilityRole: this.props.accessibilityRole, - accessibilityActions: this.props.accessibilityActions, - onAccessibilityAction: this.props.onAccessibilityAction, - accessibilityValue: { - max: this.props['aria-valuemax'] ?? this.props.accessibilityValue?.max, - min: this.props['aria-valuemin'] ?? this.props.accessibilityValue?.min, - now: this.props['aria-valuenow'] ?? this.props.accessibilityValue?.now, - text: - this.props['aria-valuetext'] ?? this.props.accessibilityValue?.text, - }, - importantForAccessibility: - this.props['aria-hidden'] === true - ? 'no-hide-descendants' - : this.props.importantForAccessibility, - accessibilityViewIsModal: - this.props['aria-modal'] ?? this.props.accessibilityViewIsModal, - accessibilityLiveRegion: - this.props['aria-live'] === 'off' - ? 'none' - : (this.props['aria-live'] ?? this.props.accessibilityLiveRegion), - accessibilityElementsHidden: - this.props['aria-hidden'] ?? this.props.accessibilityElementsHidden, - hitSlop: this.props.hitSlop, - focusable: - this.props.focusable !== false && - this.props.onPress !== undefined && - !this.props.disabled, - nativeID: this.props.id ?? this.props.nativeID, - nextFocusDown: this.props.nextFocusDown, - nextFocusForward: this.props.nextFocusForward, - nextFocusLeft: this.props.nextFocusLeft, - nextFocusRight: this.props.nextFocusRight, - nextFocusUp: this.props.nextFocusUp, - onLayout: this.props.onLayout, - testID: this.props.testID, - }; - if ( - this.props.accessibilityState != null || - this.props['aria-busy'] != null || - this.props['aria-checked'] != null || - this.props['aria-disabled'] != null || - this.props['aria-expanded'] != null || - this.props['aria-selected'] != null || - this.props.disabled != null - ) { - restProps.accessibilityState = { - busy: this.props['aria-busy'] ?? this.props.accessibilityState?.busy, - checked: - this.props['aria-checked'] ?? this.props.accessibilityState?.checked, - disabled: - this.props.disabled ?? - this.props['aria-disabled'] ?? - this.props.accessibilityState?.disabled, - expanded: - this.props['aria-expanded'] ?? - this.props.accessibilityState?.expanded, - selected: - this.props['aria-selected'] ?? - this.props.accessibilityState?.selected, - }; + const { + accessible, + background, + children: childrenProp, + delayLongPress, + delayPressIn, + delayPressOut, + disabled, + focusable, + onBlur: _onBlur, + onFocus: _onFocus, + onLongPress, + onPress, + onPressIn, + onPressOut, + pressRetentionOffset, + rejectResponderTermination, + style: _style, + touchSoundDisabled, + useForeground, + ...restProps + } = this.props; + + const pressabilityConfig = this._createPressabilityConfig(); + if (pressabilityConfig.disabled != null) { + // Expose Pressability's resolved disabled state to accessibility through View. + restProps['aria-disabled'] = pressabilityConfig.disabled; } - return cloneElement(element, restProps, ...children); + if (element.type !== View) { + const accessibilityLabel = + this.props['aria-label'] ?? this.props.accessibilityLabel; + if (accessibilityLabel != null) { + restProps.accessibilityLabel = accessibilityLabel; + } + const nativeID = this.props.id ?? this.props.nativeID; + if (nativeID != null) { + restProps.nativeID = nativeID; + } + const ariaHidden = this.props['aria-hidden']; + if (ariaHidden != null) { + restProps.accessibilityElementsHidden = ariaHidden; + if (ariaHidden === true) { + restProps.importantForAccessibility = 'no-hide-descendants'; + } + } + + const state = this.props.accessibilityState; + const busy = this.props['aria-busy'] ?? state?.busy; + const checked = this.props['aria-checked'] ?? state?.checked; + const disabledState = restProps['aria-disabled'] ?? state?.disabled; + const expanded = this.props['aria-expanded'] ?? state?.expanded; + const selected = this.props['aria-selected'] ?? state?.selected; + if ( + busy != null || + checked != null || + disabledState != null || + expanded != null || + selected != null + ) { + restProps.accessibilityState = { + busy, + checked, + disabled: disabledState, + expanded, + selected, + }; + } + + const ariaLive = this.props['aria-live']; + if (ariaLive !== undefined) { + restProps.accessibilityLiveRegion = + ariaLive === 'off' ? 'none' : ariaLive; + } + if (this.props['aria-modal'] !== undefined) { + restProps.accessibilityViewIsModal = this.props['aria-modal']; + } + if ( + this.props['aria-valuemax'] != null || + this.props['aria-valuemin'] != null || + this.props['aria-valuenow'] != null || + this.props['aria-valuetext'] != null + ) { + restProps.accessibilityValue = { + max: + this.props['aria-valuemax'] ?? this.props.accessibilityValue?.max, + min: + this.props['aria-valuemin'] ?? this.props.accessibilityValue?.min, + now: + this.props['aria-valuenow'] ?? this.props.accessibilityValue?.now, + text: + this.props['aria-valuetext'] ?? this.props.accessibilityValue?.text, + }; + } + } + + return cloneElement( + element, + { + ...restProps, + ...eventHandlersWithoutBlurAndFocus, + ...getBackgroundProp( + background === undefined + ? TouchableNativeFeedback.SelectableBackground() + : background, + useForeground === true, + ), + accessible: accessible !== false, + focusable: focusable !== false && onPress !== undefined && !disabled, + }, + ...children, + ); } componentDidUpdate( 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 0cf0cb601a6a..523f5d1f3461 100644 --- a/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableNativeFeedback-itest.js +++ b/packages/react-native/Libraries/Components/Touchable/__tests__/TouchableNativeFeedback-itest.js @@ -10,8 +10,12 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; +import type {AccessibilityProps, HostInstance, ViewProps} from 'react-native'; + import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; import * as React from 'react'; +import {createRef} from 'react'; import {Text, TouchableNativeFeedback, View} from 'react-native'; describe('', () => { @@ -20,6 +24,170 @@ describe('', () => { }); describe('rendering', () => { + it('preserves child styling when the wrapper receives a style', () => { + const root = Fantom.createRoot(); + for (const style of [null, {width: 100}]) { + Fantom.runTask(() => { + root.render( + + + , + ); + }); + expect(root.getRenderedOutput({props: ['width']}).toJSX()).toEqual( + , + ); + } + }); + + it('normalizes aliases for custom children without synthesizing empty state', () => { + const root = Fantom.createRoot(); + let received: ?ViewProps; + component Child(...props: ViewProps) { + received = props; + return ; + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + const props = nullthrows(received); + expect(props.accessibilityState).toEqual({ + busy: false, + checked: 'mixed', + disabled: false, + expanded: false, + selected: false, + }); + expect(props.accessibilityLabel).toBe(''); + expect(props.nativeID).toBe(''); + expect(props.accessibilityElementsHidden).toBe(true); + expect(props.importantForAccessibility).toBe('no-hide-descendants'); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + expect(nullthrows(received).accessibilityElementsHidden).toBe(false); + expect(nullthrows(received).importantForAccessibility).toBe('yes'); + expect(Object.keys(nullthrows(received))).not.toContain( + 'accessibilityState', + ); + }); + + it('preserves child props without adding absent view or touch-only props', () => { + const root = Fantom.createRoot(); + let childProps: ?ViewProps; + + component Child(...props: ViewProps) { + childProps = props; + return ; + } + + Fantom.runTask(() => { + root.render( + {}}> + + , + ); + }); + + const receivedProps = nullthrows(childProps); + expect(receivedProps.accessibilityLabel).toBe('Child label'); + expect(receivedProps.accessibilityState).toEqual({checked: true}); + expect(receivedProps.accessibilityValue).toEqual({text: 'Child value'}); + expect(receivedProps.nativeID).toBe('child-id'); + expect(receivedProps.testID).toBe('child-test'); + for (const prop of [ + 'accessibilityHint', + 'accessibilityLiveRegion', + 'accessibilityViewIsModal', + 'delayLongPress', + 'onPress', + ]) { + expect(Object.keys(receivedProps)).not.toContain(prop); + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(nullthrows(childProps).accessibilityLiveRegion).toBe('none'); + expect(nullthrows(childProps).accessibilityViewIsModal).toBe(true); + expect(nullthrows(childProps).accessibilityValue).toEqual({ + max: 10, + min: undefined, + now: 0, + text: undefined, + }); + }); + + it('lets View resolve accessibility aliases and their precedence', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect( + root + .getRenderedOutput({ + props: ['accessibilityLabel', 'accessibilityState'], + }) + .toJSONObject().props, + ).toEqual({ + accessibilityLabel: 'ARIA label', + accessibilityState: + '{disabled:false,selected:false,checked:Unchecked,busy:false,expanded:false}', + }); + }); + it('renders its child as pressable', () => { const root = Fantom.createRoot(); @@ -192,6 +360,47 @@ describe('', () => { } }); + it('uses inherited disabled state for presses and lets disabled override it', () => { + const root = Fantom.createRoot(); + const elementRef = createRef(); + const onPress = jest.fn(); + + const disabledProps: ReadonlyArray = [ + {'aria-disabled': true}, + {accessibilityState: {disabled: true}}, + ]; + for (const props of disabledProps) { + Fantom.runTask(() => { + root.render( + + + , + ); + }); + Fantom.dispatchNativeEvent(nullthrows(elementRef.current), 'click'); + expect(onPress).not.toHaveBeenCalled(); + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + Fantom.dispatchNativeEvent(nullthrows(elementRef.current), 'click'); + expect(onPress).toHaveBeenCalledTimes(1); + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSONObject() + .props.accessibilityState, + ).toBe( + '{disabled:false,selected:null,checked:None,busy:false,expanded:null}', + ); + }); + describe('disabled', () => { it('sets accessibilityState disabled to true', () => { const root = Fantom.createRoot();