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/React/CoreModules/RCTAccessibilityManager+Internal.h b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h index 6e180007fdc..126d168c3fc 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); +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 NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm index 04726aa4de7..6fccca87bfd 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,30 @@ 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 *RCTSystemAccessibilityPreferences(void) +{ + return @{ + @"prefersSwitchLabels" : @(UIAccessibilityIsOnOffSwitchLabelsEnabled()), + @"prefersNonColorIndicators" : @(UIAccessibilityShouldDifferentiateWithoutColor()), + }; +} + +// Replaced in tests through RCTAccessibilityManagerSetSystemReaders. +static RCTAccessibilityPreferencesReader sPreferencesReader = nil; +static RCTSwitchControlReader sSwitchControlReader = nil; + +static NSDictionary *RCTCurrentAccessibilityPreferences(void) +{ + return sPreferencesReader != nil ? sPreferencesReader() : RCTSystemAccessibilityPreferences(); +} + +static BOOL RCTIsSwitchControlRunning(void) +{ + return sSwitchControlReader != nil ? sSwitchControlReader() : UIAccessibilityIsSwitchControlRunning(); +} + @implementation RCTAccessibilityManager @synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; @@ -126,6 +151,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 +175,7 @@ - (instancetype)init _isDarkerSystemColorsEnabled = UIAccessibilityDarkerSystemColorsEnabled(); _isReduceTransparencyEnabled = UIAccessibilityIsReduceTransparencyEnabled(); _isShowBordersEnabled = RCTIsShowBordersEnabled(); + _preferences = RCTCurrentAccessibilityPreferences(); _isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); } return self; @@ -256,6 +293,22 @@ - (void)showBordersStatusDidChange:(__unused NSNotification *)notification } } +- (void)preferencesStatusDidChange:(__unused NSNotification *)notification +{ + [self _setPreferences:RCTCurrentAccessibilityPreferences()]; +} + +- (void)_setPreferences:(NSDictionary *)preferences +{ + if (![_preferences isEqualToDictionary:preferences]) { + _preferences = [preferences copy]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"preferencesChanged" body:_preferences]; +#pragma clang diagnostic pop + } +} + - (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification { BOOL isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); @@ -470,6 +523,18 @@ - (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 +{ + // 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: (const facebook::react::ObjCTurboModule::InitParams &)params { @@ -485,6 +550,14 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( [accessibilityManager _setIsVoiceOverEnabled:isVoiceOverEnabled]; } +void RCTAccessibilityManagerSetSystemReaders( + RCTAccessibilityPreferencesReader _Nullable preferencesReader, + RCTSwitchControlReader _Nullable switchControlReader) +{ + sPreferencesReader = preferencesReader; + sSwitchControlReader = switchControlReader; +} + @end @implementation RCTBridge (RCTAccessibilityManager) 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/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..ff7dc4b8ef3 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m @@ -0,0 +1,183 @@ +/* + * 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 +#import + +// 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 + +@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; + 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; + _manager = [RCTAccessibilityManager new]; + _manager.moduleRegistry = _moduleRegistry; +} + +- (void)tearDown +{ + RCTAccessibilityManagerSetSystemReaders(nil, nil); + _manager = nil; + [super tearDown]; +} + +- (NSDictionary *)currentPreferences +{ + __block NSDictionary *preferences = nil; + [_manager + getCurrentPreferences:^(NSArray *response) { + preferences = response.firstObject; + } + onError:^(__unused NSArray *response) { + XCTFail(@"getCurrentPreferences failed"); + }]; + return preferences; +} + +- (NSNumber *)recommendedTimeoutFor:(double)mSec +{ + __block NSNumber *timeout = nil; + [_manager getRecommendedTimeoutMillis:mSec + onSuccess:^(NSArray *response) { + timeout = response.firstObject; + }]; + return timeout; +} + +- (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)testNotificationWithoutAChangeSendsNoEvent +{ + [[NSNotificationCenter defaultCenter] postNotificationName:UIAccessibilityOnOffSwitchLabelsDidChangeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification + object:nil]; + + XCTAssertEqual(_eventDispatcher.events.count, 0); +} + +- (void)testRecommendedTimeoutIsLongerWhileSwitchControlIsRunning +{ + XCTAssertEqualObjects([self recommendedTimeoutFor:4000], @4000); + + _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 ff289110413..26edbf71e86 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'; @@ -23,6 +27,7 @@ import {createRef, useEffect, useRef, useState} from 'react'; import { AccessibilityInfo, Alert, + AppState, Button, Image, ImageBackground, @@ -1606,6 +1611,7 @@ class DisplayOptionsStatusExample extends React.Component<{}> { /> )} + ); } @@ -1647,6 +1653,65 @@ 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); + }, + ); + // 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 ( + + + {'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'};