From 642b8036f9fb0d32f3c6814143b2606149820d6b Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:14:01 +0100 Subject: [PATCH 1/3] Add AccessibilityInfo.getPreferences() and preferencesChanged Add a preferences snapshot for accessibility settings that change how an app should draw its own controls, named by what the user needs rather than by each platform's setting: prefersSwitchLabels (iOS On/Off Labels) and prefersNonColorIndicators (iOS Differentiate Without Color). Android has neither setting, so it resolves both to false. preferencesChanged carries the whole new snapshot. getRecommendedTimeoutMillis now asks the iOS module too, so iOS can return a longer timeout while Switch Control is running. Adds Jest tests, type tests, the API snapshot and an RNTester row. The iOS native side follows in the next commit. Co-Authored-By: Claude Opus 5.5 --- .../jest/mocks/AccessibilityInfo.js | 6 ++ .../AccessibilityInfo/AccessibilityInfo.js | 65 +++++++++++++- ...cessibilityInfo-preferencesChanged-test.js | 82 +++++++++++++++++ .../__tests__/AccessibilityInfo-test.js | 89 ++++++++++++++++++- packages/react-native/ReactNativeApi.d.ts | 11 ++- packages/react-native/__typetests__/index.tsx | 17 ++++ packages/react-native/index.js.flow | 1 + .../modules/NativeAccessibilityManager.js | 11 +++ .../AccessibilityInfo/AccessibilityInfo.d.ts | 46 +++++++++- .../Accessibility/AccessibilityExample.js | 55 +++++++++++- 10 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index d49f63d51eb..b0f992af63f 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -20,6 +20,12 @@ const AccessibilityInfo = { isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, + getPreferences: jest.fn(() => + Promise.resolve({ + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }), + ) as JestMockFn<$FlowFixMe, $FlowFixMe>, isShowBordersEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn< $FlowFixMe, $FlowFixMe, diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 017c1d3c842..64748277758 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -18,6 +18,24 @@ import legacySendAccessibilityEvent from './legacySendAccessibilityEvent'; import NativeAccessibilityInfoAndroid from './NativeAccessibilityInfo'; import NativeAccessibilityManagerIOS from './NativeAccessibilityManager'; +/** + * Accessibility settings that change how an app should draw its own controls, + * named by what the user needs rather than by each platform's setting. A field + * is `false` on a platform that has no such setting. + */ +export type AccessibilityPreferences = { + // On/Off Labels (iOS): draw I/O marks on custom switches. + prefersSwitchLabels: boolean, + // Differentiate Without Color (iOS): convey state with shapes, icons or text, + // not by color alone. + prefersNonColorIndicators: boolean, +}; + +const NEUTRAL_PREFERENCES: AccessibilityPreferences = { + prefersSwitchLabels: false, + prefersNonColorIndicators: false, +}; + // Events that are only supported on Android. type AccessibilityEventDefinitionsAndroid = { accessibilityServiceChanged: [boolean], @@ -29,6 +47,7 @@ type AccessibilityEventDefinitionsIOS = { announcementFinished: [{announcement: string, success: boolean}], boldTextChanged: [boolean], darkerSystemColorsChanged: [boolean], + preferencesChanged: [AccessibilityPreferences], reduceTransparencyChanged: [boolean], showBordersChanged: [boolean], }; @@ -69,6 +88,7 @@ const EventNames: Map = ['screenReaderChanged', 'screenReaderChanged'], ['darkerSystemColorsChanged', 'darkerSystemColorsChanged'], ['showBordersChanged', 'showBordersChanged'], + ['preferencesChanged', 'preferencesChanged'], ]); /** @@ -319,6 +339,27 @@ const AccessibilityInfo = { } }, + /** + * Query the accessibility preferences that change how custom controls should + * be drawn (see `AccessibilityPreferences`). + * + * Returns a promise which resolves to an `AccessibilityPreferences` object. + * On Android, which has none of these settings, every field is `false`. + */ + getPreferences(): Promise { + if (Platform.OS === 'android') { + return Promise.resolve({...NEUTRAL_PREFERENCES}); + } else { + return new Promise((resolve, reject) => { + if (NativeAccessibilityManagerIOS?.getCurrentPreferences != null) { + NativeAccessibilityManagerIOS.getCurrentPreferences(resolve, reject); + } else { + resolve({...NEUTRAL_PREFERENCES}); + } + }); + } + }, + /** * Query whether reduced transparency is currently enabled. * @@ -442,6 +483,8 @@ const AccessibilityInfo = { * - `showBordersChanged`: iOS-only event. Fires when the state of the show borders toggle changes * (called Button Shapes before iOS 26.1). * The argument to the event handler is a boolean. + * - `preferencesChanged`: iOS-only event. Fires when any field of `getPreferences()` changes. + * The argument to the event handler is the new `AccessibilityPreferences` object. * * These events are only supported on Android: * @@ -528,10 +571,13 @@ const AccessibilityInfo = { }, /** - * Get the recommended timeout in milliseconds the user needs, as specified - * in the device's Accessibility settings. + * Get the recommended timeout in milliseconds the user needs, given the + * timeout the app would otherwise use. * - * @platform android + * On Android, this follows the "Time to take action" accessibility setting. + * On iOS, it is three times `originalTimeout` while Switch Control is running, + * because moving between elements one switch press at a time is slow, and + * `originalTimeout` otherwise. */ getRecommendedTimeoutMillis(originalTimeout: number): Promise { if (Platform.OS === 'android') { @@ -548,7 +594,18 @@ const AccessibilityInfo = { } }); } else { - return Promise.resolve(originalTimeout); + return new Promise(resolve => { + if ( + NativeAccessibilityManagerIOS?.getRecommendedTimeoutMillis != null + ) { + NativeAccessibilityManagerIOS.getRecommendedTimeoutMillis( + originalTimeout, + resolve, + ); + } else { + resolve(originalTimeout); + } + }); } }, }; diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js new file mode 100644 index 00000000000..2e9282ddcf8 --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js @@ -0,0 +1,82 @@ +/** + * 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 RCTDeviceEventEmitterType from '../../../EventEmitter/RCTDeviceEventEmitter'; +import typeof AccessibilityInfoType from '../AccessibilityInfo'; + +const invariant = require('invariant'); + +jest.unmock('../AccessibilityInfo'); + +type LoadedModules = { + AccessibilityInfo: AccessibilityInfoType, + RCTDeviceEventEmitter: RCTDeviceEventEmitterType, +}; + +// `AccessibilityInfo` picks its native event names when it is first loaded, +// so each platform needs a fresh module registry. +function loadForPlatform(os: 'android' | 'ios'): LoadedModules { + let modules: ?LoadedModules = null; + jest.isolateModules(() => { + const Platform = require('../../../Utilities/Platform').default; + /* $FlowFixMe[incompatible-type] */ + Platform.OS = os; + modules = { + AccessibilityInfo: require('../AccessibilityInfo').default, + RCTDeviceEventEmitter: + require('../../../EventEmitter/RCTDeviceEventEmitter').default, + }; + }); + invariant(modules != null, 'Expected modules to load'); + return modules; +} + +describe('AccessibilityInfo preferencesChanged', () => { + it('delivers the preferences snapshot on iOS', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'preferencesChanged', + handler, + ); + const preferences = { + prefersSwitchLabels: true, + prefersNonColorIndicators: false, + }; + RCTDeviceEventEmitter.emit('preferencesChanged', preferences); + subscription.remove(); + RCTDeviceEventEmitter.emit('preferencesChanged', { + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(preferences); + }); + + it('does not subscribe on Android', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = + loadForPlatform('android'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'preferencesChanged', + handler, + ); + RCTDeviceEventEmitter.emit('preferencesChanged', { + prefersSwitchLabels: true, + prefersNonColorIndicators: true, + }); + subscription.remove(); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js index 380be9fdd39..eb2ff639402 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -22,6 +22,17 @@ const mockGetCurrentShowBordersState = jest.fn((onSuccess, onError) => const mockGetCurrentBoldTextState = jest.fn((onSuccess, onError) => onSuccess(true), ); +const IOS_PREFERENCES = { + prefersSwitchLabels: true, + prefersNonColorIndicators: false, +}; +const mockGetCurrentPreferences = jest.fn((onSuccess, onError) => + onSuccess(IOS_PREFERENCES), +); +const IOS_RECOMMENDED_TIMEOUT = 15000; +const mockGetRecommendedTimeoutMillisIOS = jest.fn( + (originalTimeout, onSuccess) => onSuccess(IOS_RECOMMENDED_TIMEOUT), +); const mockGetCurrentGrayscaleState = jest.fn((onSuccess, onError) => onSuccess(true), ); @@ -38,6 +49,20 @@ const mockGetCurrentVoiceOverState = jest.fn((onSuccess, onError) => onSuccess(true), ); let mockNativeAccessibilityManagerIOS: { + getCurrentPreferences: JestMockFn< + [ + onSuccess: (preferences: { + prefersSwitchLabels: boolean, + prefersNonColorIndicators: boolean, + }) => void, + onError: (error: Error) => void, + ], + void, + > | null, + getRecommendedTimeoutMillis: JestMockFn< + [originalTimeout: number, onSuccess: (timeout: number) => void], + void, + > | null, getCurrentShowBordersState: JestMockFn< [ onSuccess: (isShowBordersEnabled: boolean) => void, @@ -102,6 +127,8 @@ let mockNativeAccessibilityManagerIOS: { void, > | null, } = { + getCurrentPreferences: mockGetCurrentPreferences, + getRecommendedTimeoutMillis: mockGetRecommendedTimeoutMillisIOS, getCurrentShowBordersState: mockGetCurrentShowBordersState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, @@ -831,10 +858,26 @@ describe('AccessibilityInfo', () => { }); describe('iOS', () => { - it('should return originalTimeout', async () => { + it('should return the native recommended timeout', async () => { /* $FlowFixMe[incompatible-type] */ Platform.OS = 'ios'; + const recommendedTimeoutMillis = + await AccessibilityInfo.getRecommendedTimeoutMillis(5000); + + expect(mockGetRecommendedTimeoutMillisIOS).toHaveBeenCalledWith( + 5000, + expect.any(Function), + ); + expect(recommendedTimeoutMillis).toBe(IOS_RECOMMENDED_TIMEOUT); + }); + + it('should return originalTimeout if getRecommendedTimeoutMillis is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + mockNativeAccessibilityManagerIOS.getRecommendedTimeoutMillis = null; + const recommendedTimeoutMillis = await AccessibilityInfo.getRecommendedTimeoutMillis(5000); @@ -843,8 +886,52 @@ describe('AccessibilityInfo', () => { }); }); + describe('getPreferences', () => { + describe('Android', () => { + it('should return neutral preferences immediately', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const preferences = await AccessibilityInfo.getPreferences(); + + expect(preferences).toEqual({ + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }); + }); + }); + + describe('iOS', () => { + it('should call getCurrentPreferences if available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + const preferences = await AccessibilityInfo.getPreferences(); + + expect(mockGetCurrentPreferences).toHaveBeenCalled(); + expect(preferences).toEqual(IOS_PREFERENCES); + }); + + it('should return neutral preferences if getCurrentPreferences is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + mockNativeAccessibilityManagerIOS.getCurrentPreferences = null; + + const preferences = await AccessibilityInfo.getPreferences(); + + expect(preferences).toEqual({ + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }); + }); + }); + }); + afterEach(() => { mockNativeAccessibilityManagerIOS = { + getCurrentPreferences: mockGetCurrentPreferences, + getRecommendedTimeoutMillis: mockGetRecommendedTimeoutMillisIOS, getCurrentShowBordersState: mockGetCurrentShowBordersState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 89b0fe69edd..6437148cb66 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<29785e09db994cf0d43e7426f47c1b30>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -91,6 +91,7 @@ declare const AccessibilityInfo_default: { queue?: boolean }, ): void + getPreferences(): Promise getRecommendedTimeoutMillis(originalTimeout: number): Promise isAccessibilityServiceEnabled(): Promise isBoldTextEnabled(): Promise @@ -974,12 +975,17 @@ declare type AccessibilityEventDefinitionsIOS = { ] boldTextChanged: [boolean] darkerSystemColorsChanged: [boolean] + preferencesChanged: [AccessibilityPreferences] reduceTransparencyChanged: [boolean] showBordersChanged: [boolean] } declare type AccessibilityEventTypes = "click" | "focus" | "viewHoverEnter" | "windowStateChange" declare type AccessibilityInfo = typeof AccessibilityInfo +declare type AccessibilityPreferences = { + prefersNonColorIndicators: boolean + prefersSwitchLabels: boolean +} declare interface AccessibilityProps extends Readonly< AccessibilityPropsAndroid & AccessibilityPropsIOS & AccessibilityPropsCore > {} @@ -5773,7 +5779,8 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // 7d8718ed + AccessibilityInfo, // 053f578c + AccessibilityPreferences, // bea42d9b AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 75e5dce1634..88090a3d963 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -23,6 +23,7 @@ For a list of complete Typescript examples: check https://github.com/bgrieder/RN import * as React from 'react'; import { AccessibilityInfo, + AccessibilityPreferences, ActionSheetIOS, Alert, Animated, @@ -1470,6 +1471,22 @@ AccessibilityInfo.isShowBordersEnabled().then(isEnabled => AccessibilityInfo.addEventListener('showBordersChanged', isEnabled => console.log(`AccessibilityInfo.showBordersChanged => ${isEnabled}`), ); +AccessibilityInfo.getPreferences().then( + (preferences: AccessibilityPreferences) => + console.log( + `AccessibilityInfo.getPreferences => ${preferences.prefersSwitchLabels} ${preferences.prefersNonColorIndicators}`, + ), +); +AccessibilityInfo.addEventListener( + 'preferencesChanged', + (preferences: AccessibilityPreferences) => + console.log( + `AccessibilityInfo.preferencesChanged => ${preferences.prefersSwitchLabels}`, + ), +); +AccessibilityInfo.getRecommendedTimeoutMillis(4000).then((timeout: number) => + console.log(`AccessibilityInfo.getRecommendedTimeoutMillis => ${timeout}`), +); AccessibilityInfo.isBoldTextEnabled().then(isEnabled => console.log(`AccessibilityInfo.isBoldTextEnabled => ${isEnabled}`), ); diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 113127b308b..6c89ba4e150 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -242,6 +242,7 @@ export {default as VirtualizedSectionList} from './Libraries/Lists/VirtualizedSe // #endregion // #region APIs +export type {AccessibilityPreferences} from './Libraries/Components/AccessibilityInfo/AccessibilityInfo'; export {default as AccessibilityInfo} from './Libraries/Components/AccessibilityInfo/AccessibilityInfo'; export type { diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js index 0dfb462fe67..617e79f945a 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js @@ -42,6 +42,13 @@ export interface Spec extends TurboModule { onSuccess: (isShowBordersEnabled: boolean) => void, onError: (error: UnsafeObject) => void, ) => void; + readonly getCurrentPreferences?: ( + onSuccess: (preferences: { + prefersSwitchLabels: boolean, + prefersNonColorIndicators: boolean, + }) => void, + onError: (error: UnsafeObject) => void, + ) => void; readonly getCurrentReduceTransparencyState: ( onSuccess: (isReduceTransparencyEnabled: boolean) => void, onError: (error: UnsafeObject) => void, @@ -50,6 +57,10 @@ export interface Spec extends TurboModule { onSuccess: (isScreenReaderEnabled: boolean) => void, onError: (error: UnsafeObject) => void, ) => void; + readonly getRecommendedTimeoutMillis?: ( + mSec: number, + onSuccess: (recommendedTimeoutMillis: number) => void, + ) => void; readonly setAccessibilityContentSizeMultipliers: (JSMultipliers: { readonly extraSmall?: ?number, readonly small?: ?number, diff --git a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index 6f2ccb47832..fc8e392ce4f 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts @@ -47,6 +47,27 @@ type AccessibilityAnnouncementFinishedEventHandler = ( event: AccessibilityAnnouncementFinishedEvent, ) => void; +/** + * Accessibility settings that change how an app should draw its own controls, + * named by what the user needs rather than by each platform's setting. A field + * is `false` on a platform that has no such setting. + */ +export interface AccessibilityPreferences { + /** On/Off Labels (iOS): draw I/O marks on custom switches. */ + prefersSwitchLabels: boolean; + /** + * Differentiate Without Color (iOS): convey state with shapes, icons or + * text, not by color alone. + */ + prefersNonColorIndicators: boolean; +} + +type AccessibilityPreferencesEventName = 'preferencesChanged'; // iOS-only Event + +type AccessibilityPreferencesEventHandler = ( + preferences: AccessibilityPreferences, +) => void; + type AccessibilityEventTypes = 'click' | 'focus' | 'viewHoverEnter' | 'windowStateChange'; @@ -95,6 +116,12 @@ export interface AccessibilityInfoStatic { */ isShowBordersEnabled: () => Promise; + /** + * Query the accessibility preferences that change how custom controls + * should be drawn. On Android, every field is `false`. + */ + getPreferences: () => Promise; + /** * Query whether darker system colors is currently enabled. * @@ -151,6 +178,16 @@ export interface AccessibilityInfoStatic { eventName: AccessibilityAnnouncementEventName, handler: AccessibilityAnnouncementFinishedEventHandler, ): EmitterSubscription; + /** + * Fires when any field of `getPreferences()` changes, with the new + * preferences. + * + * @platform ios + */ + addEventListener( + eventName: AccessibilityPreferencesEventName, + handler: AccessibilityPreferencesEventHandler, + ): EmitterSubscription; /** * Set accessibility focus to a react component. @@ -182,10 +219,13 @@ export interface AccessibilityInfoStatic { ): void; /** - * Gets the timeout in millisecond that the user needs. - * This value is set in "Time to take action (Accessibility timeout)" of "Accessibility" settings. + * Gets the timeout in millisecond that the user needs, given the timeout + * the app would otherwise use. * - * @platform android + * On Android, this value is set in "Time to take action (Accessibility + * timeout)" of "Accessibility" settings. On iOS, it is three times + * `originalTimeout` while Switch Control is running, and `originalTimeout` + * otherwise. */ getRecommendedTimeoutMillis: (originalTimeout: number) => Promise; sendAccessibilityEvent: ( diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index ff289110413..06f39872a70 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -11,7 +11,11 @@ 'use strict'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; -import type {EventSubscription, GestureResponderEvent} from 'react-native'; +import type { + AccessibilityPreferences, + EventSubscription, + GestureResponderEvent, +} from 'react-native'; import RNTesterBlock from '../../components/RNTesterBlock'; import RNTesterText from '../../components/RNTesterText'; @@ -1606,6 +1610,7 @@ class DisplayOptionsStatusExample extends React.Component<{}> { /> )} + ); } @@ -1647,6 +1652,54 @@ function DisplayOptionStatusExample({ ); } +function describePreferences(preferences: AccessibilityPreferences): string { + return ( + `switch labels ${preferences.prefersSwitchLabels ? 'on' : 'off'}, ` + + `non-color indicators ${preferences.prefersNonColorIndicators ? 'on' : 'off'}` + ); +} + +function PreferencesStatusExample(): React.Node { + const [preferences, setPreferences] = + useState(null); + const [lastEvent, setLastEvent] = useState(null); + const [recommendedTimeout, setRecommendedTimeout] = useState(null); + useEffect(() => { + const listener = AccessibilityInfo.addEventListener( + 'preferencesChanged', + newPreferences => { + setPreferences(newPreferences); + setLastEvent(newPreferences); + }, + ); + // $FlowFixMe[unused-promise] + AccessibilityInfo.getPreferences().then(setPreferences); + // $FlowFixMe[unused-promise] + AccessibilityInfo.getRecommendedTimeoutMillis(4000).then( + setRecommendedTimeout, + ); + return function cleanup() { + listener.remove(); + }; + }, []); + return ( + + + {'Preferences: '} + {preferences == null ? 'loading' : describePreferences(preferences)}. + + + {'Last preferencesChanged: '} + {lastEvent == null ? 'none' : describePreferences(lastEvent)}. + + + {'Recommended timeout for 4000 ms: '} + {recommendedTimeout == null ? 'loading' : `${recommendedTimeout} ms`}. + + + ); +} + function AccessibilityExpandedExample(): React.Node { const [expand, setExpanded] = useState(false); const expandAction = {name: 'expand'}; From e9de122f5740b37c92bc7fcca5cd876c5b810163 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:20:28 +0100 Subject: [PATCH 2/3] Read accessibility preferences and the Switch Control timeout on iOS RCTAccessibilityManager builds the getPreferences() snapshot from On/Off Labels (UIAccessibilityIsOnOffSwitchLabelsEnabled) and Differentiate Without Color (UIAccessibilityShouldDifferentiateWithoutColor), observes both settings' notifications, and sends preferencesChanged with the whole snapshot only when it changes. getRecommendedTimeoutMillis returns three times the original timeout while Switch Control is running, because moving between elements one switch press at a time is slow. Adds RCTAccessibilityManagerTests for the timeout and for sending the event only on a change. Co-Authored-By: Claude Opus 5.5 --- .../RCTAccessibilityManager+Internal.h | 9 ++ .../CoreModules/RCTAccessibilityManager.mm | 65 ++++++++++ .../RNTesterPods.xcodeproj/project.pbxproj | 4 + .../RCTAccessibilityManagerTests.m | 112 ++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h index 6e180007fdc..2040cbac122 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h @@ -19,6 +19,15 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( RCTAccessibilityManager *accessibilityManager, BOOL isVoiceOverEnabled); +// Applies a preferences snapshot and sends `preferencesChanged` if it differs +// from the last one. Returns whether it sent the event. +BOOL RCTAccessibilityManagerSetPreferences( + RCTAccessibilityManager *accessibilityManager, + NSDictionary *preferences); + +// The timeout returned by getRecommendedTimeoutMillis. +double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning); + RCT_EXTERN_C_END NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm index 04726aa4de7..b41d7eeaf5b 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm @@ -26,6 +26,7 @@ @interface RCTAccessibilityManager () @property (nonatomic, copy) NSString *contentSizeCategory; @property (nonatomic, assign) CGFloat multiplier; +@property (nonatomic, copy) NSDictionary *preferences; @end @@ -57,6 +58,23 @@ static NSNotificationName RCTShowBordersStatusDidChangeNotification() #pragma clang diagnostic pop } +// The settings behind AccessibilityInfo.getPreferences(), named by what the user +// needs rather than by the iOS setting. +static NSDictionary *RCTCurrentAccessibilityPreferences() +{ + return @{ + @"prefersSwitchLabels" : @(UIAccessibilityIsOnOffSwitchLabelsEnabled()), + @"prefersNonColorIndicators" : @(UIAccessibilityShouldDifferentiateWithoutColor()), + }; +} + +double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning) +{ + // Moving between elements one switch press at a time is slow, so allow + // three times as long while Switch Control is running. + return isSwitchControlRunning ? originalTimeout * 3 : originalTimeout; +} + @implementation RCTAccessibilityManager @synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; @@ -126,6 +144,17 @@ - (instancetype)init name:RCTShowBordersStatusDidChangeNotification() object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(preferencesStatusDidChange:) + name:UIAccessibilityOnOffSwitchLabelsDidChangeNotification + object:nil]; + + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(preferencesStatusDidChange:) + name:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(voiceVoiceOverStatusDidChange:) name:UIAccessibilityVoiceOverStatusDidChangeNotification @@ -139,6 +168,7 @@ - (instancetype)init _isDarkerSystemColorsEnabled = UIAccessibilityDarkerSystemColorsEnabled(); _isReduceTransparencyEnabled = UIAccessibilityIsReduceTransparencyEnabled(); _isShowBordersEnabled = RCTIsShowBordersEnabled(); + _preferences = RCTCurrentAccessibilityPreferences(); _isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); } return self; @@ -256,6 +286,24 @@ - (void)showBordersStatusDidChange:(__unused NSNotification *)notification } } +- (void)preferencesStatusDidChange:(__unused NSNotification *)notification +{ + [self _setPreferences:RCTCurrentAccessibilityPreferences()]; +} + +- (BOOL)_setPreferences:(NSDictionary *)preferences +{ + if ([_preferences isEqualToDictionary:preferences]) { + return NO; + } + _preferences = [preferences copy]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"preferencesChanged" body:_preferences]; +#pragma clang diagnostic pop + return YES; +} + - (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification { BOOL isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); @@ -470,6 +518,16 @@ - (void)getCurrentVoiceOverState:(RCTResponseSenderBlock)onSuccess onError:(__un onSuccess(@[ @(_isVoiceOverEnabled) ]); } +- (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError +{ + onSuccess(@[ _preferences ]); +} + +- (void)getRecommendedTimeoutMillis:(double)mSec onSuccess:(RCTResponseSenderBlock)onSuccess +{ + onSuccess(@[ @(RCTRecommendedTimeoutMillis(mSec, UIAccessibilityIsSwitchControlRunning())) ]); +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { @@ -485,6 +543,13 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( [accessibilityManager _setIsVoiceOverEnabled:isVoiceOverEnabled]; } +BOOL RCTAccessibilityManagerSetPreferences( + RCTAccessibilityManager *accessibilityManager, + NSDictionary *preferences) +{ + return [accessibilityManager _setPreferences:preferences]; +} + @end @implementation RCTBridge (RCTAccessibilityManager) diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 725e8a28823..ad21d09b64b 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ E7DB20DF22B2BAA6005AC45F /* RCTImageLoaderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C222B2BAA4005AC45F /* RCTImageLoaderTests.m */; }; E7DB20E022B2BAA6005AC45F /* RCTMethodArgumentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C322B2BAA4005AC45F /* RCTMethodArgumentTests.m */; }; E7DB20E222B2BAA6005AC45F /* RCTGzipTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C522B2BAA4005AC45F /* RCTGzipTests.m */; }; + A11E0F0D2EA6000100A11E0F /* RCTAccessibilityManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A11E0F0C2EA6000100A11E0F /* RCTAccessibilityManagerTests.m */; }; E7DB20E322B2BAA6005AC45F /* RCTAllocationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C622B2BAA5005AC45F /* RCTAllocationTests.m */; }; E7DB20E422B2BAA6005AC45F /* RCTFormatErrorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */; }; E7DB20E522B2BAA6005AC45F /* RCTDevMenuTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20C822B2BAA5005AC45F /* RCTDevMenuTests.m */; }; @@ -126,6 +127,7 @@ E7DB20C222B2BAA4005AC45F /* RCTImageLoaderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTImageLoaderTests.m; sourceTree = ""; }; E7DB20C322B2BAA4005AC45F /* RCTMethodArgumentTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMethodArgumentTests.m; sourceTree = ""; }; E7DB20C522B2BAA4005AC45F /* RCTGzipTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTGzipTests.m; sourceTree = ""; }; + A11E0F0C2EA6000100A11E0F /* RCTAccessibilityManagerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTAccessibilityManagerTests.m; sourceTree = ""; }; E7DB20C622B2BAA5005AC45F /* RCTAllocationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTAllocationTests.m; sourceTree = ""; }; E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTFormatErrorTests.m; sourceTree = ""; }; E7DB20C822B2BAA5005AC45F /* RCTDevMenuTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTDevMenuTests.m; sourceTree = ""; }; @@ -335,6 +337,7 @@ E7DB20AF22B2BAA4005AC45F /* RCTFontTests.m */, E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */, E7DB20C522B2BAA4005AC45F /* RCTGzipTests.m */, + A11E0F0C2EA6000100A11E0F /* RCTAccessibilityManagerTests.m */, E7DB20AE22B2BAA4005AC45F /* RCTImageLoaderHelpers.h */, E7DB20C922B2BAA5005AC45F /* RCTImageLoaderHelpers.m */, E7DB20C222B2BAA4005AC45F /* RCTImageLoaderTests.m */, @@ -763,6 +766,7 @@ E7DB20E822B2BAA6005AC45F /* RCTModuleMethodTests.mm in Sources */, F1A0B1C23D4E5F6071829302 /* RCTTurboModuleArrayBufferTests.mm in Sources */, E7DB20E222B2BAA6005AC45F /* RCTGzipTests.m in Sources */, + A11E0F0D2EA6000100A11E0F /* RCTAccessibilityManagerTests.m in Sources */, E7DB20ED22B2BAA6005AC45F /* RCTURLUtilsTests.m in Sources */, E7DB20D322B2BAA6005AC45F /* RCTBlobManagerTests.m in Sources */, E7DB20E322B2BAA6005AC45F /* RCTAllocationTests.m in Sources */, diff --git a/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m new file mode 100644 index 00000000000..3ebc879e6c2 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m @@ -0,0 +1,112 @@ +/* + * 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. + */ + +#import + +#import +#import + +extern BOOL RCTAccessibilityManagerSetPreferences( + RCTAccessibilityManager *accessibilityManager, + NSDictionary *preferences); +extern double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning); + +// Records the device events the module sends. +@interface RCTAccessibilityManagerTestsEventDispatcher : NSObject + +@property (nonatomic, strong) NSMutableArray *events; + +- (void)sendDeviceEventWithName:(NSString *)name body:(id)body; + +@end + +@implementation RCTAccessibilityManagerTestsEventDispatcher + +- (instancetype)init +{ + if (self = [super init]) { + _events = [NSMutableArray new]; + } + return self; +} + +- (void)sendDeviceEventWithName:(NSString *)name body:(id)body +{ + [_events addObject:@[ name, body ]]; +} + +@end + +@interface RCTAccessibilityManagerTestsModuleRegistry : RCTModuleRegistry + +@property (nonatomic, strong) RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher; + +@end + +@implementation RCTAccessibilityManagerTestsModuleRegistry + +- (id)moduleForName:(const char *)moduleName +{ + return strcmp(moduleName, "EventDispatcher") == 0 ? _eventDispatcher : nil; +} + +@end + +@interface RCTAccessibilityManagerTests : XCTestCase + +@end + +@implementation RCTAccessibilityManagerTests { + RCTAccessibilityManager *_manager; + RCTAccessibilityManagerTestsEventDispatcher *_eventDispatcher; + // The module holds its registry weakly. + RCTAccessibilityManagerTestsModuleRegistry *_moduleRegistry; +} + +- (void)setUp +{ + [super setUp]; + _eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + _moduleRegistry = [RCTAccessibilityManagerTestsModuleRegistry new]; + _moduleRegistry.eventDispatcher = _eventDispatcher; + _manager = [RCTAccessibilityManager new]; + _manager.moduleRegistry = _moduleRegistry; +} + +- (void)testRecommendedTimeoutIsLongerWhileSwitchControlIsRunning +{ + XCTAssertEqual(RCTRecommendedTimeoutMillis(4000, YES), 12000); + XCTAssertEqual(RCTRecommendedTimeoutMillis(4000, NO), 4000); + XCTAssertEqual(RCTRecommendedTimeoutMillis(0, YES), 0); +} + +- (void)testUnchangedPreferencesSendNoEvent +{ + NSDictionary *preferences = @{@"prefersSwitchLabels" : @YES, @"prefersNonColorIndicators" : @NO}; + + RCTAccessibilityManagerSetPreferences(_manager, preferences); + [_eventDispatcher.events removeAllObjects]; + BOOL sent = RCTAccessibilityManagerSetPreferences(_manager, [preferences copy]); + + XCTAssertFalse(sent); + XCTAssertEqual(_eventDispatcher.events.count, 0); +} + +- (void)testChangedPreferencesSendTheWholeSnapshot +{ + RCTAccessibilityManagerSetPreferences(_manager, @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @NO}); + [_eventDispatcher.events removeAllObjects]; + + NSDictionary *changed = @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @YES}; + BOOL sent = RCTAccessibilityManagerSetPreferences(_manager, changed); + + XCTAssertTrue(sent); + XCTAssertEqual(_eventDispatcher.events.count, 1); + XCTAssertEqualObjects(_eventDispatcher.events.firstObject, (@[ @"preferencesChanged", changed ])); +} + +@end From 0d680aa4a56d2e9b9be8fa2501edafa4456d1dd2 Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:56:35 +0100 Subject: [PATCH 3/3] Test accessibility preferences through notifications and native calls RCTAccessibilityManager now reads the On/Off Labels, Differentiate Without Color and Switch Control settings through readers that tests can replace (RCTAccessibilityManagerSetSystemReaders, in +Internal.h). The tests post the real UIKit notifications and call getCurrentPreferences and getRecommendedTimeoutMillis, so they cover the observers, the deduplication and the native callbacks, not just a helper. This replaces the two earlier test hooks. RNTester's preferences row also reads the snapshot and the recommended timeout again when the app becomes active, because Switch Control has no change event. Co-Authored-By: Claude Opus 5.5 --- .../RCTAccessibilityManager+Internal.h | 16 +-- .../CoreModules/RCTAccessibilityManager.mm | 42 ++++--- .../RCTAccessibilityManagerTests.m | 117 ++++++++++++++---- .../Accessibility/AccessibilityExample.js | 24 +++- 4 files changed, 145 insertions(+), 54 deletions(-) diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h index 2040cbac122..126d168c3fc 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h @@ -19,14 +19,14 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( RCTAccessibilityManager *accessibilityManager, BOOL isVoiceOverEnabled); -// Applies a preferences snapshot and sends `preferencesChanged` if it differs -// from the last one. Returns whether it sent the event. -BOOL RCTAccessibilityManagerSetPreferences( - RCTAccessibilityManager *accessibilityManager, - NSDictionary *preferences); - -// The timeout returned by getRecommendedTimeoutMillis. -double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning); +typedef NSDictionary *_Nonnull (^RCTAccessibilityPreferencesReader)(void); +typedef BOOL (^RCTSwitchControlReader)(void); + +// Replaces how the module reads the system settings behind getPreferences() +// and getRecommendedTimeoutMillis. Pass nil to read the system again. +void RCTAccessibilityManagerSetSystemReaders( + RCTAccessibilityPreferencesReader _Nullable preferencesReader, + RCTSwitchControlReader _Nullable switchControlReader); RCT_EXTERN_C_END diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm index b41d7eeaf5b..6fccca87bfd 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm @@ -60,7 +60,7 @@ static NSNotificationName RCTShowBordersStatusDidChangeNotification() // The settings behind AccessibilityInfo.getPreferences(), named by what the user // needs rather than by the iOS setting. -static NSDictionary *RCTCurrentAccessibilityPreferences() +static NSDictionary *RCTSystemAccessibilityPreferences(void) { return @{ @"prefersSwitchLabels" : @(UIAccessibilityIsOnOffSwitchLabelsEnabled()), @@ -68,11 +68,18 @@ static NSNotificationName RCTShowBordersStatusDidChangeNotification() }; } -double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning) +// Replaced in tests through RCTAccessibilityManagerSetSystemReaders. +static RCTAccessibilityPreferencesReader sPreferencesReader = nil; +static RCTSwitchControlReader sSwitchControlReader = nil; + +static NSDictionary *RCTCurrentAccessibilityPreferences(void) { - // Moving between elements one switch press at a time is slow, so allow - // three times as long while Switch Control is running. - return isSwitchControlRunning ? originalTimeout * 3 : originalTimeout; + return sPreferencesReader != nil ? sPreferencesReader() : RCTSystemAccessibilityPreferences(); +} + +static BOOL RCTIsSwitchControlRunning(void) +{ + return sSwitchControlReader != nil ? sSwitchControlReader() : UIAccessibilityIsSwitchControlRunning(); } @implementation RCTAccessibilityManager @@ -291,17 +298,15 @@ - (void)preferencesStatusDidChange:(__unused NSNotification *)notification [self _setPreferences:RCTCurrentAccessibilityPreferences()]; } -- (BOOL)_setPreferences:(NSDictionary *)preferences +- (void)_setPreferences:(NSDictionary *)preferences { - if ([_preferences isEqualToDictionary:preferences]) { - return NO; - } - _preferences = [preferences copy]; + if (![_preferences isEqualToDictionary:preferences]) { + _preferences = [preferences copy]; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"preferencesChanged" body:_preferences]; + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"preferencesChanged" body:_preferences]; #pragma clang diagnostic pop - return YES; + } } - (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification @@ -525,7 +530,9 @@ - (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(__unuse - (void)getRecommendedTimeoutMillis:(double)mSec onSuccess:(RCTResponseSenderBlock)onSuccess { - onSuccess(@[ @(RCTRecommendedTimeoutMillis(mSec, UIAccessibilityIsSwitchControlRunning())) ]); + // Moving between elements one switch press at a time is slow, so allow + // three times as long while Switch Control is running. + onSuccess(@[ @(RCTIsSwitchControlRunning() ? mSec * 3 : mSec) ]); } - (std::shared_ptr)getTurboModule: @@ -543,11 +550,12 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( [accessibilityManager _setIsVoiceOverEnabled:isVoiceOverEnabled]; } -BOOL RCTAccessibilityManagerSetPreferences( - RCTAccessibilityManager *accessibilityManager, - NSDictionary *preferences) +void RCTAccessibilityManagerSetSystemReaders( + RCTAccessibilityPreferencesReader _Nullable preferencesReader, + RCTSwitchControlReader _Nullable switchControlReader) { - return [accessibilityManager _setPreferences:preferences]; + sPreferencesReader = preferencesReader; + sSwitchControlReader = switchControlReader; } @end diff --git a/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m index 3ebc879e6c2..ff7dc4b8ef3 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m @@ -5,15 +5,26 @@ * LICENSE file in the root directory of this source tree. */ +#import #import #import #import -extern BOOL RCTAccessibilityManagerSetPreferences( - RCTAccessibilityManager *accessibilityManager, - NSDictionary *preferences); -extern double RCTRecommendedTimeoutMillis(double originalTimeout, BOOL isSwitchControlRunning); +// From RCTAccessibilityManager+Internal.h. +typedef NSDictionary * (^RCTAccessibilityPreferencesReader)(void); +typedef BOOL (^RCTSwitchControlReader)(void); +extern void RCTAccessibilityManagerSetSystemReaders( + RCTAccessibilityPreferencesReader preferencesReader, + RCTSwitchControlReader switchControlReader); + +// The native module methods JS calls, from the generated spec. +@interface RCTAccessibilityManager (RCTAccessibilityManagerTests) + +- (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(RCTResponseSenderBlock)onError; +- (void)getRecommendedTimeoutMillis:(double)mSec onSuccess:(RCTResponseSenderBlock)onSuccess; + +@end // Records the device events the module sends. @interface RCTAccessibilityManagerTestsEventDispatcher : NSObject @@ -65,11 +76,26 @@ @implementation RCTAccessibilityManagerTests { RCTAccessibilityManagerTestsEventDispatcher *_eventDispatcher; // The module holds its registry weakly. RCTAccessibilityManagerTestsModuleRegistry *_moduleRegistry; + NSDictionary *_systemPreferences; + BOOL _isSwitchControlRunning; } - (void)setUp { [super setUp]; + _systemPreferences = @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @NO}; + _isSwitchControlRunning = NO; + __weak RCTAccessibilityManagerTests *weakSelf = self; + RCTAccessibilityManagerSetSystemReaders( + ^NSDictionary * { + RCTAccessibilityManagerTests *strongSelf = weakSelf; + return strongSelf ? strongSelf->_systemPreferences : @{}; + }, + ^BOOL { + RCTAccessibilityManagerTests *strongSelf = weakSelf; + return strongSelf ? strongSelf->_isSwitchControlRunning : NO; + }); + _eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; _moduleRegistry = [RCTAccessibilityManagerTestsModuleRegistry new]; _moduleRegistry.eventDispatcher = _eventDispatcher; @@ -77,36 +103,81 @@ - (void)setUp _manager.moduleRegistry = _moduleRegistry; } -- (void)testRecommendedTimeoutIsLongerWhileSwitchControlIsRunning +- (void)tearDown { - XCTAssertEqual(RCTRecommendedTimeoutMillis(4000, YES), 12000); - XCTAssertEqual(RCTRecommendedTimeoutMillis(4000, NO), 4000); - XCTAssertEqual(RCTRecommendedTimeoutMillis(0, YES), 0); + RCTAccessibilityManagerSetSystemReaders(nil, nil); + _manager = nil; + [super tearDown]; } -- (void)testUnchangedPreferencesSendNoEvent +- (NSDictionary *)currentPreferences { - NSDictionary *preferences = @{@"prefersSwitchLabels" : @YES, @"prefersNonColorIndicators" : @NO}; + __block NSDictionary *preferences = nil; + [_manager + getCurrentPreferences:^(NSArray *response) { + preferences = response.firstObject; + } + onError:^(__unused NSArray *response) { + XCTFail(@"getCurrentPreferences failed"); + }]; + return preferences; +} - RCTAccessibilityManagerSetPreferences(_manager, preferences); - [_eventDispatcher.events removeAllObjects]; - BOOL sent = RCTAccessibilityManagerSetPreferences(_manager, [preferences copy]); +- (NSNumber *)recommendedTimeoutFor:(double)mSec +{ + __block NSNumber *timeout = nil; + [_manager getRecommendedTimeoutMillis:mSec + onSuccess:^(NSArray *response) { + timeout = response.firstObject; + }]; + return timeout; +} - XCTAssertFalse(sent); - XCTAssertEqual(_eventDispatcher.events.count, 0); +- (void)testGetCurrentPreferencesReturnsTheSettingsAtStartup +{ + XCTAssertEqualObjects( + [self currentPreferences], (@{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @NO})); +} + +- (void)testOnOffSwitchLabelsChangeSendsTheWholeSnapshot +{ + _systemPreferences = @{@"prefersSwitchLabels" : @YES, @"prefersNonColorIndicators" : @NO}; + [[NSNotificationCenter defaultCenter] postNotificationName:UIAccessibilityOnOffSwitchLabelsDidChangeNotification + object:nil]; + + XCTAssertEqualObjects(_eventDispatcher.events, (@[ @[ @"preferencesChanged", _systemPreferences ] ])); + XCTAssertEqualObjects([self currentPreferences], _systemPreferences); +} + +- (void)testDifferentiateWithoutColorChangeSendsTheWholeSnapshot +{ + _systemPreferences = @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @YES}; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification + object:nil]; + + XCTAssertEqualObjects(_eventDispatcher.events, (@[ @[ @"preferencesChanged", _systemPreferences ] ])); + XCTAssertEqualObjects([self currentPreferences], _systemPreferences); } -- (void)testChangedPreferencesSendTheWholeSnapshot +- (void)testNotificationWithoutAChangeSendsNoEvent { - RCTAccessibilityManagerSetPreferences(_manager, @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @NO}); - [_eventDispatcher.events removeAllObjects]; + [[NSNotificationCenter defaultCenter] postNotificationName:UIAccessibilityOnOffSwitchLabelsDidChangeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification + object:nil]; + + XCTAssertEqual(_eventDispatcher.events.count, 0); +} - NSDictionary *changed = @{@"prefersSwitchLabels" : @NO, @"prefersNonColorIndicators" : @YES}; - BOOL sent = RCTAccessibilityManagerSetPreferences(_manager, changed); +- (void)testRecommendedTimeoutIsLongerWhileSwitchControlIsRunning +{ + XCTAssertEqualObjects([self recommendedTimeoutFor:4000], @4000); - XCTAssertTrue(sent); - XCTAssertEqual(_eventDispatcher.events.count, 1); - XCTAssertEqualObjects(_eventDispatcher.events.firstObject, (@[ @"preferencesChanged", changed ])); + _isSwitchControlRunning = YES; + XCTAssertEqualObjects([self recommendedTimeoutFor:4000], @12000); + XCTAssertEqualObjects([self recommendedTimeoutFor:0], @0); } @end diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 06f39872a70..26edbf71e86 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -27,6 +27,7 @@ import {createRef, useEffect, useRef, useState} from 'react'; import { AccessibilityInfo, Alert, + AppState, Button, Image, ImageBackground, @@ -1672,14 +1673,25 @@ function PreferencesStatusExample(): React.Node { setLastEvent(newPreferences); }, ); - // $FlowFixMe[unused-promise] - AccessibilityInfo.getPreferences().then(setPreferences); - // $FlowFixMe[unused-promise] - AccessibilityInfo.getRecommendedTimeoutMillis(4000).then( - setRecommendedTimeout, - ); + // Switch Control has no change event, so read again when the app comes + // back from Settings. + const read = () => { + // $FlowFixMe[unused-promise] + AccessibilityInfo.getPreferences().then(setPreferences); + // $FlowFixMe[unused-promise] + AccessibilityInfo.getRecommendedTimeoutMillis(4000).then( + setRecommendedTimeout, + ); + }; + read(); + const appStateListener = AppState.addEventListener('change', state => { + if (state === 'active') { + read(); + } + }); return function cleanup() { listener.remove(); + appStateListener.remove(); }; }, []); return (