diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index d49f63d51ebf..3f8ea17984c7 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -4,10 +4,12 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * @flow strict-local * @format */ +import type {AccessibilityPreferences} from 'react-native'; + const AccessibilityInfo = { addEventListener: jest.fn(() => ({ remove: jest.fn(), @@ -20,6 +22,12 @@ const AccessibilityInfo = { isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, + getPreferences: jest.fn(() => + Promise.resolve({ + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }), + ) as JestMockFn<[], Promise>, 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 017c1d3c842e..2e5112059b60 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -18,6 +18,27 @@ 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. + * + * Fields will be added over time, so read the object that `getPreferences()` + * or `preferencesChanged` gives you rather than building one yourself. + */ +export type AccessibilityPreferences = Readonly<{ + // 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 +50,7 @@ type AccessibilityEventDefinitionsIOS = { announcementFinished: [{announcement: string, success: boolean}], boldTextChanged: [boolean], darkerSystemColorsChanged: [boolean], + preferencesChanged: [AccessibilityPreferences], reduceTransparencyChanged: [boolean], showBordersChanged: [boolean], }; @@ -69,6 +91,7 @@ const EventNames: Map = ['screenReaderChanged', 'screenReaderChanged'], ['darkerSystemColorsChanged', 'darkerSystemColorsChanged'], ['showBordersChanged', 'showBordersChanged'], + ['preferencesChanged', 'accessibilityPreferencesChanged'], ]); /** @@ -319,6 +342,43 @@ 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`. + * On iOS, rejects if the native module or method is missing (for example, + * JS newer than the app binary). + */ + getPreferences(): Promise { + if (Platform.OS === 'android') { + return Promise.resolve({...NEUTRAL_PREFERENCES}); + } else { + return new Promise((resolve, reject) => { + if (NativeAccessibilityManagerIOS?.getCurrentPreferences != null) { + // Besides `AccessibilityPreferences`, the fields are written by + // hand inline in the native module spec (a named type would add C++ + // structs to the API snapshots) and in the TypeScript types. + // Passing `resolve` as `onSuccess` makes Flow's exact object types + // check the spec against `AccessibilityPreferences`; + // `__typetests__` covers the TypeScript types. The native keys in + // RCTAccessibilityManager.mm must match too. + NativeAccessibilityManagerIOS.getCurrentPreferences(resolve, reject); + } else { + // A new API, so a missing native method rejects, as + // `isShowBordersEnabled` does, rather than resolving a snapshot that + // looks like every setting is off. + reject( + new Error( + 'NativeAccessibilityManagerIOS.getCurrentPreferences is not available', + ), + ); + } + }); + } + }, + /** * Query whether reduced transparency is currently enabled. * @@ -442,6 +502,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: * 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 000000000000..bcea62074508 --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-preferencesChanged-test.js @@ -0,0 +1,100 @@ +/** + * 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('accessibilityPreferencesChanged', preferences); + subscription.remove(); + RCTDeviceEventEmitter.emit('accessibilityPreferencesChanged', { + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(preferences); + }); + + it('ignores a bare preferencesChanged device event on iOS', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'preferencesChanged', + handler, + ); + // Another module could emit this name on the shared emitter. + RCTDeviceEventEmitter.emit('preferencesChanged', { + prefersSwitchLabels: true, + prefersNonColorIndicators: true, + }); + subscription.remove(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not subscribe on Android', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = + loadForPlatform('android'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'preferencesChanged', + handler, + ); + RCTDeviceEventEmitter.emit('accessibilityPreferencesChanged', { + 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 380be9fdd394..0b951843801b 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,13 @@ const mockGetCurrentShowBordersState = jest.fn((onSuccess, onError) => const mockGetCurrentBoldTextState = jest.fn((onSuccess, onError) => onSuccess(true), ); +const IOS_PREFERENCES: AccessibilityPreferences = { + prefersSwitchLabels: true, + prefersNonColorIndicators: false, +}; +const mockGetCurrentPreferences = jest.fn((onSuccess, onError) => + onSuccess(IOS_PREFERENCES), +); const mockGetCurrentGrayscaleState = jest.fn((onSuccess, onError) => onSuccess(true), ); @@ -38,6 +45,13 @@ const mockGetCurrentVoiceOverState = jest.fn((onSuccess, onError) => onSuccess(true), ); let mockNativeAccessibilityManagerIOS: { + getCurrentPreferences: JestMockFn< + [ + onSuccess: (preferences: AccessibilityPreferences) => void, + onError: (error: Error) => void, + ], + void, + > | null, getCurrentShowBordersState: JestMockFn< [ onSuccess: (isShowBordersEnabled: boolean) => void, @@ -102,6 +116,7 @@ let mockNativeAccessibilityManagerIOS: { void, > | null, } = { + getCurrentPreferences: mockGetCurrentPreferences, getCurrentShowBordersState: mockGetCurrentShowBordersState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, @@ -173,6 +188,8 @@ jest.mock('../NativeAccessibilityInfo', () => ({ default: mockNativeAccessibilityInfoAndroid, })); +import type {AccessibilityPreferences} from '../AccessibilityInfo'; + const Platform = require('../../../Utilities/Platform').default; const AccessibilityInfo = require('../AccessibilityInfo').default; const invariant = require('invariant'); @@ -831,10 +848,14 @@ describe('AccessibilityInfo', () => { }); describe('iOS', () => { - it('should return originalTimeout', async () => { + it('should return originalTimeout, as the iOS module has no getRecommendedTimeoutMillis', async () => { /* $FlowFixMe[incompatible-type] */ Platform.OS = 'ios'; + expect( + jest.requireMock('../NativeAccessibilityManager').default, + ).not.toHaveProperty('getRecommendedTimeoutMillis'); + const recommendedTimeoutMillis = await AccessibilityInfo.getRecommendedTimeoutMillis(5000); @@ -843,8 +864,54 @@ describe('AccessibilityInfo', () => { }); }); + describe('getPreferences', () => { + describe('Android', () => { + it('should return neutral preferences without calling native', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const preferences = await AccessibilityInfo.getPreferences(); + + expect(preferences).toEqual({ + prefersSwitchLabels: false, + prefersNonColorIndicators: false, + }); + expect(mockGetCurrentPreferences).not.toHaveBeenCalled(); + }); + }); + + 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 throw error if getCurrentPreferences is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + mockNativeAccessibilityManagerIOS.getCurrentPreferences = null; + + const result: unknown = await AccessibilityInfo.getPreferences().catch( + e => e, + ); + + invariant(result instanceof Error, 'Expected getPreferences to reject'); + expect(result.message).toEqual( + 'NativeAccessibilityManagerIOS.getCurrentPreferences is not available', + ); + }); + }); + }); + afterEach(() => { mockNativeAccessibilityManagerIOS = { + getCurrentPreferences: mockGetCurrentPreferences, 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 6e180007fdca..1c0808fd2f34 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager+Internal.h @@ -19,6 +19,17 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( RCTAccessibilityManager *accessibilityManager, BOOL isVoiceOverEnabled); +// Returns the raw settings behind getPreferences(), keyed by the +// AccessibilityPreferences field names. Returning nil reads the system. +typedef NSDictionary *_Nullable (^RCTAccessibilityPreferencesReader)(void); + +// Replaces how this module reads the settings behind getPreferences(), and +// re-reads them without sending accessibilityPreferencesChanged. Pass nil to +// read the system settings again. +void RCTAccessibilityManagerSetPreferencesReader( + RCTAccessibilityManager *accessibilityManager, + RCTAccessibilityPreferencesReader _Nullable preferencesReader); + 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 dee5e643c758..0381620687a8 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm @@ -56,7 +56,48 @@ static NSNotificationName RCTShowBordersStatusDidChangeNotification() #pragma clang diagnostic pop } -@implementation RCTAccessibilityManager +static NSDictionary *RCTSystemAccessibilityPreferences(void) +{ + return @{ + @"prefersSwitchLabels" : @(UIAccessibilityIsOnOffSwitchLabelsEnabled()), + @"prefersNonColorIndicators" : @(UIAccessibilityShouldDifferentiateWithoutColor()), + }; +} + +static BOOL RCTAccessibilityPreferenceValue(id value) +{ + return [value respondsToSelector:@selector(boolValue)] && [value boolValue]; +} + +// Builds the AccessibilityInfo.getPreferences() snapshot from `reader`, or from +// the system settings when `reader` is nil. The keys must match the fields of +// AccessibilityPreferences in AccessibilityInfo.js. Only those keys are read, +// and each value is boxed from a BOOL, so the snapshot always has exactly these +// keys and every value is a CFBoolean, whatever the reader returns. +static NSDictionary *RCTCurrentAccessibilityPreferences( + RCTAccessibilityPreferencesReader _Nullable reader) +{ + NSDictionary *raw = nil; + if (reader != nil) { + raw = reader(); + if (raw == nil) { + RCTLogWarn(@"The accessibility preferences reader returned nil, so the system settings are used instead."); + } + } + if (raw == nil) { + raw = RCTSystemAccessibilityPreferences(); + } + return @{ + @"prefersSwitchLabels" : @(RCTAccessibilityPreferenceValue(raw[@"prefersSwitchLabels"])), + @"prefersNonColorIndicators" : @(RCTAccessibilityPreferenceValue(raw[@"prefersNonColorIndicators"])), + }; +} + +@implementation RCTAccessibilityManager { + NSDictionary *_preferences; + // Replaced in tests through RCTAccessibilityManagerSetPreferencesReader. + RCTAccessibilityPreferencesReader _preferencesReader; +} @synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; @synthesize moduleRegistry = _moduleRegistry; @@ -125,6 +166,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 @@ -138,6 +190,7 @@ - (instancetype)init _isDarkerSystemColorsEnabled = UIAccessibilityDarkerSystemColorsEnabled(); _isReduceTransparencyEnabled = UIAccessibilityIsReduceTransparencyEnabled(); _isShowBordersEnabled = RCTIsShowBordersEnabled(); + _preferences = RCTCurrentAccessibilityPreferences(nil); _isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); } return self; @@ -255,6 +308,19 @@ - (void)showBordersStatusDidChange:(__unused NSNotification *)notification } } +- (void)preferencesStatusDidChange:(__unused NSNotification *)notification +{ + NSDictionary *newPreferences = RCTCurrentAccessibilityPreferences(_preferencesReader); + if (![_preferences isEqualToDictionary:newPreferences]) { + _preferences = newPreferences; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"accessibilityPreferencesChanged" + body:_preferences]; +#pragma clang diagnostic pop + } +} + - (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification { BOOL isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); @@ -469,6 +535,11 @@ - (void)getCurrentVoiceOverState:(RCTResponseSenderBlock)onSuccess onError:(__un onSuccess(@[ @(_isVoiceOverEnabled) ]); } +- (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError +{ + onSuccess(@[ _preferences ]); +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { @@ -484,6 +555,14 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled( [accessibilityManager _setIsVoiceOverEnabled:isVoiceOverEnabled]; } +void RCTAccessibilityManagerSetPreferencesReader( + RCTAccessibilityManager *accessibilityManager, + RCTAccessibilityPreferencesReader _Nullable preferencesReader) +{ + accessibilityManager->_preferencesReader = [preferencesReader copy]; + accessibilityManager->_preferences = RCTCurrentAccessibilityPreferences(accessibilityManager->_preferencesReader); +} + @end @implementation RCTBridgeProxy (RCTAccessibilityManager) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c0f8309f0ac5..c7d0a1a92f2a 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<> + * @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 = { + readonly prefersNonColorIndicators: boolean + readonly prefersSwitchLabels: boolean +} declare interface AccessibilityProps extends Readonly< AccessibilityPropsAndroid & AccessibilityPropsIOS & AccessibilityPropsCore > {} @@ -5775,7 +5781,8 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // 7d8718ed + AccessibilityInfo, // f03a8242 + AccessibilityPreferences, // 9861c4a0 AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 75e5dce1634c..6b50653a821e 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,19 @@ 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.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 113127b308b2..6c89ba4e1505 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 0dfb462fe674..a574cde67d6d 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, 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 6f2ccb478328..a97c61b6128a 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,30 @@ 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. + * + * Fields will be added over time, so read the object that `getPreferences()` + * or `preferencesChanged` gives you rather than building one yourself. + */ +export interface AccessibilityPreferences { + /** On/Off Labels (iOS): draw I/O marks on custom switches. */ + readonly prefersSwitchLabels: boolean; + /** + * Differentiate Without Color (iOS): convey state with shapes, icons or + * text, not by color alone. + */ + readonly prefersNonColorIndicators: boolean; +} + +type AccessibilityPreferencesEventName = 'preferencesChanged'; // iOS-only Event + +type AccessibilityPreferencesEventHandler = ( + preferences: AccessibilityPreferences, +) => void; + type AccessibilityEventTypes = 'click' | 'focus' | 'viewHoverEnter' | 'windowStateChange'; @@ -95,6 +119,14 @@ export interface AccessibilityInfoStatic { */ isShowBordersEnabled: () => Promise; + /** + * Query the accessibility preferences that change how custom controls + * should be drawn. On Android, every field is `false`. + * On iOS, rejects if the native module or method is missing (for example, + * JS newer than the app binary). + */ + getPreferences: () => Promise; + /** * Query whether darker system colors is currently enabled. * @@ -151,6 +183,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. diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 1d336b424136..51ae4cd2bc1b 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -37,6 +37,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 */; }; @@ -123,6 +124,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 = ""; }; @@ -333,6 +335,7 @@ E7DB20AF22B2BAA4005AC45F /* RCTFontTests.m */, E7DB20C722B2BAA5005AC45F /* RCTFormatErrorTests.m */, E7DB20C522B2BAA4005AC45F /* RCTGzipTests.m */, + A11E0F0C2EA6000100A11E0F /* RCTAccessibilityManagerTests.m */, E7DB20AE22B2BAA4005AC45F /* RCTImageLoaderHelpers.h */, E7DB20C922B2BAA5005AC45F /* RCTImageLoaderHelpers.m */, E7DB20C222B2BAA4005AC45F /* RCTImageLoaderTests.m */, @@ -760,6 +763,7 @@ F1A0B1C23D4E5F6071829302 /* RCTTurboModuleArrayBufferTests.mm in Sources */, F1A0B1C23D4E5F6071829322 /* RCTTurboModuleStringConversionTests.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 000000000000..48a4b003ba95 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTAccessibilityManagerTests.m @@ -0,0 +1,335 @@ +/* + * 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 +#import +#import + +// The native module method JS calls, from the generated spec. +@interface RCTAccessibilityManager (RCTAccessibilityManagerTests) + +- (void)getCurrentPreferences:(RCTResponseSenderBlock)onSuccess onError:(RCTResponseSenderBlock)onError; + +@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 + +// Holds the settings a test reader returns, so a test can change them after +// installing the reader. Installing a reader refreshes the module's cache, so +// only a notification can be what sends an event. +@interface RCTAccessibilityManagerTestsSettings : NSObject + +@property (nonatomic, copy) NSDictionary *preferences; + +@end + +@implementation RCTAccessibilityManagerTestsSettings + +@end + +static NSDictionary *RCTTestPreferences( + BOOL prefersSwitchLabels, + BOOL prefersNonColorIndicators) +{ + return @{ + @"prefersSwitchLabels" : @(prefersSwitchLabels), + @"prefersNonColorIndicators" : @(prefersNonColorIndicators), + }; +} + +static BOOL RCTIsCFBoolean(id value) +{ + return value != nil && CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID(); +} + +@interface RCTAccessibilityManagerTests : XCTestCase + +@end + +@implementation RCTAccessibilityManagerTests { + // The module holds its registry weakly, so the test keeps each one alive. + NSMutableArray *_moduleRegistries; +} + +- (void)setUp +{ + [super setUp]; + _moduleRegistries = [NSMutableArray new]; +} + +- (void)tearDown +{ + RCTSetLogFunction(RCTDefaultLogFunction); + _moduleRegistries = nil; + [super tearDown]; +} + +- (RCTAccessibilityManager *)managerWithEventDispatcher:(RCTAccessibilityManagerTestsEventDispatcher *)eventDispatcher +{ + RCTAccessibilityManagerTestsModuleRegistry *moduleRegistry = [RCTAccessibilityManagerTestsModuleRegistry new]; + moduleRegistry.eventDispatcher = eventDispatcher; + [_moduleRegistries addObject:moduleRegistry]; + RCTAccessibilityManager *manager = [RCTAccessibilityManager new]; + manager.moduleRegistry = moduleRegistry; + return manager; +} + +- (void)installReaderOverSettings:(RCTAccessibilityManagerTestsSettings *)settings + onManager:(RCTAccessibilityManager *)manager +{ + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return settings.preferences; + }); +} + +- (NSDictionary *)currentPreferencesOf:(RCTAccessibilityManager *)manager +{ + __block NSDictionary *preferences = nil; + [manager + getCurrentPreferences:^(NSArray *response) { + preferences = response.firstObject; + } + onError:^(__unused NSArray *response) { + XCTFail(@"getCurrentPreferences failed"); + }]; + return preferences; +} + +- (void)postNotification:(NSNotificationName)name +{ + [[NSNotificationCenter defaultCenter] postNotificationName:name object:nil]; +} + +- (void)testSystemReaderAtStartup +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + + NSDictionary *preferences = [self currentPreferencesOf:manager]; + + XCTAssertEqualObjects( + [NSSet setWithArray:preferences.allKeys], + ([NSSet setWithArray:@[ @"prefersSwitchLabels", @"prefersNonColorIndicators" ]])); + XCTAssertTrue(RCTIsCFBoolean(preferences[@"prefersSwitchLabels"])); + XCTAssertTrue(RCTIsCFBoolean(preferences[@"prefersNonColorIndicators"])); + XCTAssertEqualObjects(preferences[@"prefersSwitchLabels"], @(UIAccessibilityIsOnOffSwitchLabelsEnabled())); + XCTAssertEqualObjects(preferences[@"prefersNonColorIndicators"], @(UIAccessibilityShouldDifferentiateWithoutColor())); + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +- (void)testSetterReplacesThePreferencesWithoutAnEvent +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return RCTTestPreferences(YES, YES); + }); + + XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(YES, YES)); + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +- (void)testOnOffSwitchLabelsChangeSendsOneEvent +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + RCTAccessibilityManagerTestsSettings *settings = [RCTAccessibilityManagerTestsSettings new]; + settings.preferences = RCTTestPreferences(NO, NO); + [self installReaderOverSettings:settings onManager:manager]; + + settings.preferences = RCTTestPreferences(YES, NO); + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + + XCTAssertEqualObjects( + eventDispatcher.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(YES, NO) ] ])); + XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(YES, NO)); +} + +- (void)testDifferentiateWithoutColorChangeSendsOneEvent +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + RCTAccessibilityManagerTestsSettings *settings = [RCTAccessibilityManagerTestsSettings new]; + settings.preferences = RCTTestPreferences(NO, NO); + [self installReaderOverSettings:settings onManager:manager]; + + settings.preferences = RCTTestPreferences(NO, YES); + [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; + [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; + + XCTAssertEqualObjects( + eventDispatcher.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(NO, YES) ] ])); + XCTAssertEqualObjects([self currentPreferencesOf:manager], RCTTestPreferences(NO, YES)); +} + +- (void)testNotificationAfterTheSetterWithTheSameValuesSendsNoEvent +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + RCTAccessibilityManagerTestsSettings *settings = [RCTAccessibilityManagerTestsSettings new]; + settings.preferences = RCTTestPreferences(YES, YES); + [self installReaderOverSettings:settings onManager:manager]; + + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + [self postNotification:UIAccessibilityShouldDifferentiateWithoutColorDidChangeNotification]; + + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +- (void)testSeparateInstancesUseTheirOwnReaders +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcherA = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcherB = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *managerA = [self managerWithEventDispatcher:eventDispatcherA]; + RCTAccessibilityManager *managerB = [self managerWithEventDispatcher:eventDispatcherB]; + XCTAssertNotNil(managerA); + XCTAssertNotNil(managerB); + RCTAccessibilityManagerTestsSettings *settingsA = [RCTAccessibilityManagerTestsSettings new]; + RCTAccessibilityManagerTestsSettings *settingsB = [RCTAccessibilityManagerTestsSettings new]; + settingsA.preferences = RCTTestPreferences(NO, NO); + settingsB.preferences = RCTTestPreferences(NO, NO); + [self installReaderOverSettings:settingsA onManager:managerA]; + [self installReaderOverSettings:settingsB onManager:managerB]; + + settingsA.preferences = RCTTestPreferences(YES, NO); + settingsB.preferences = RCTTestPreferences(NO, YES); + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + + XCTAssertEqualObjects( + eventDispatcherA.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(YES, NO) ] ])); + XCTAssertEqualObjects( + eventDispatcherB.events, (@[ @[ @"accessibilityPreferencesChanged", RCTTestPreferences(NO, YES) ] ])); +} + +- (void)testReaderValuesReachTheEventAsCFBooleans +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + RCTAccessibilityManagerTestsSettings *settings = [RCTAccessibilityManagerTestsSettings new]; + settings.preferences = RCTTestPreferences(NO, NO); + [self installReaderOverSettings:settings onManager:manager]; + + settings.preferences = @{@"prefersSwitchLabels" : @1, @"prefersNonColorIndicators" : @0, @"unknownKey" : @YES}; + [self postNotification:UIAccessibilityOnOffSwitchLabelsDidChangeNotification]; + + XCTAssertEqual(eventDispatcher.events.count, 1); + NSDictionary *body = eventDispatcher.events.firstObject[1]; + XCTAssertEqualObjects(body, RCTTestPreferences(YES, NO)); + XCTAssertTrue(RCTIsCFBoolean(body[@"prefersSwitchLabels"])); + XCTAssertTrue(RCTIsCFBoolean(body[@"prefersNonColorIndicators"])); + XCTAssertEqual(body.count, 2); +} + +- (void)testReaderReturningNilFallsBackToTheSystem +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + BOOL systemSwitchLabels = UIAccessibilityIsOnOffSwitchLabelsEnabled(); + BOOL systemNonColorIndicators = UIAccessibilityShouldDifferentiateWithoutColor(); + // Start from the opposite of the system values, so a cache left unchanged + // would not match the system values below. + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return RCTTestPreferences(!systemSwitchLabels, !systemNonColorIndicators); + }); + XCTAssertEqualObjects( + [self currentPreferencesOf:manager], RCTTestPreferences(!systemSwitchLabels, !systemNonColorIndicators)); + NSMutableArray *warnings = [NSMutableArray new]; + RCTSetLogFunction( + ^(RCTLogLevel level, + __unused RCTLogSource source, + __unused NSString *fileName, + __unused NSNumber *lineNumber, + NSString *message) { + if (level == RCTLogLevelWarning) { + [warnings addObject:message]; + } + }); + + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return nil; + }); + + XCTAssertEqual(warnings.count, 1); + XCTAssertTrue([warnings.firstObject containsString:@"reader returned nil"]); + XCTAssertEqualObjects( + [self currentPreferencesOf:manager], RCTTestPreferences(systemSwitchLabels, systemNonColorIndicators)); + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +- (void)testResettingTheReaderReturnsTheSystemValues +{ + RCTAccessibilityManagerTestsEventDispatcher *eventDispatcher = [RCTAccessibilityManagerTestsEventDispatcher new]; + RCTAccessibilityManager *manager = [self managerWithEventDispatcher:eventDispatcher]; + XCTAssertNotNil(manager); + BOOL systemSwitchLabels = UIAccessibilityIsOnOffSwitchLabelsEnabled(); + BOOL systemNonColorIndicators = UIAccessibilityShouldDifferentiateWithoutColor(); + RCTAccessibilityManagerSetPreferencesReader(manager, ^NSDictionary * { + return RCTTestPreferences(!systemSwitchLabels, !systemNonColorIndicators); + }); + XCTAssertEqualObjects( + [self currentPreferencesOf:manager], RCTTestPreferences(!systemSwitchLabels, !systemNonColorIndicators)); + + RCTAccessibilityManagerSetPreferencesReader(manager, nil); + + XCTAssertEqualObjects( + [self currentPreferencesOf:manager], RCTTestPreferences(systemSwitchLabels, systemNonColorIndicators)); + XCTAssertEqual(eventDispatcher.events.count, 0); +} + +@end diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index ff2891104139..9334b9562807 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,66 @@ 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 [error, setError] = useState(null); + const [lastEvent, setLastEvent] = useState(null); + useEffect(() => { + let isMounted = true; + // An event carries newer preferences than a starting read still in flight. + let receivedEvent = false; + const listener = AccessibilityInfo.addEventListener( + 'preferencesChanged', + newPreferences => { + receivedEvent = true; + setPreferences(newPreferences); + setLastEvent(newPreferences); + }, + ); + AccessibilityInfo.getPreferences() + .then(newPreferences => { + if (isMounted && !receivedEvent) { + setPreferences(newPreferences); + } + }) + .catch((e: Error) => { + if (isMounted && !receivedEvent) { + setError(e.message); + } + }); + return function cleanup() { + isMounted = false; + listener.remove(); + }; + }, []); + let status = 'loading'; + if (preferences != null) { + status = describePreferences(preferences); + } else if (error != null) { + status = `Unavailable: ${error}`; + } + return ( + + + {'Preferences: '} + {status}. + + + {'Last preferencesChanged: '} + {lastEvent == null ? 'none' : describePreferences(lastEvent)}. + + + ); +} + function AccessibilityExpandedExample(): React.Node { const [expand, setExpanded] = useState(false); const expandAction = {name: 'expand'}; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 8de2c49759a6..0e078b8bd927 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -2147,6 +2147,7 @@ protocol NativeAccessibilityManagerSpec : public NSObjectRCTBridgeModule, public public virtual void getCurrentDarkerSystemColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentGrayscaleState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentInvertColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); + public virtual void getCurrentPreferences:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentPrefersCrossFadeTransitionsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceMotionState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceTransparencyState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index 6b5e45141f07..6a07a95a76c1 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -2205,6 +2205,7 @@ protocol NativeAccessibilityManagerSpec : public NSObjectRCTBridgeModule, public public virtual void getCurrentDarkerSystemColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentGrayscaleState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentInvertColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); + public virtual void getCurrentPreferences:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentPrefersCrossFadeTransitionsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceMotionState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceTransparencyState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index e8201541b2e1..4d8d1a0e5706 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -2140,6 +2140,7 @@ protocol NativeAccessibilityManagerSpec : public NSObjectRCTBridgeModule, public public virtual void getCurrentDarkerSystemColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentGrayscaleState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentInvertColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); + public virtual void getCurrentPreferences:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentPrefersCrossFadeTransitionsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceMotionState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceTransparencyState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index d3ff97cc59c6..0a787f97bd2e 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -2147,6 +2147,7 @@ protocol NativeAccessibilityManagerSpec : public NSObjectRCTBridgeModule, public public virtual void getCurrentDarkerSystemColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentGrayscaleState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentInvertColorsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); + public virtual void getCurrentPreferences:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentPrefersCrossFadeTransitionsState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceMotionState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError); public virtual void getCurrentReduceTransparencyState:onError:(RCTResponseSenderBlock onSuccess, RCTResponseSenderBlock onError);