From d5184a2ac03f9d4634ebce928d75830e4d95924b Mon Sep 17 00:00:00 2001 From: Camilo Pires <45287958+camilopires@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:40:17 +0100 Subject: [PATCH] Add AccessibilityInfo.isOnOffSwitchLabelsEnabled on iOS Reads UIAccessibilityIsOnOffSwitchLabelsEnabled() and emits onOffSwitchLabelsChanged when it changes. Android resolves to false. Adds Jest tests for the getter and the event wiring, and an RNTester row. Co-Authored-By: Claude Opus 5.5 --- .../jest/mocks/AccessibilityInfo.js | 3 + .../AccessibilityInfo/AccessibilityInfo.js | 37 ++++++++++ ...ilityInfo-onOffSwitchLabelsChanged-test.js | 72 +++++++++++++++++++ .../__tests__/AccessibilityInfo-test.js | 56 +++++++++++++++ .../CoreModules/RCTAccessibilityManager.h | 1 + .../CoreModules/RCTAccessibilityManager.mm | 25 +++++++ packages/react-native/ReactNativeApi.d.ts | 6 +- packages/react-native/__typetests__/index.tsx | 6 ++ .../modules/NativeAccessibilityManager.js | 4 ++ .../AccessibilityInfo/AccessibilityInfo.d.ts | 11 ++- .../Accessibility/AccessibilityExample.js | 5 ++ 11 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-onOffSwitchLabelsChanged-test.js diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index 4dc215bcabd..48fef70011a 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -20,6 +20,9 @@ const AccessibilityInfo = { isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, + isOnOffSwitchLabelsEnabled: jest.fn(() => + Promise.resolve(false), + ) as JestMockFn<$FlowFixMe, $FlowFixMe>, isBoldTextEnabled: 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 b922c77f062..dcccf52756d 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -30,6 +30,7 @@ type AccessibilityEventDefinitionsIOS = { boldTextChanged: [boolean], darkerSystemColorsChanged: [boolean], reduceTransparencyChanged: [boolean], + onOffSwitchLabelsChanged: [boolean], }; type AccessibilityEventDefinitions = { @@ -67,6 +68,7 @@ const EventNames: Map = ['reduceTransparencyChanged', 'reduceTransparencyChanged'], ['screenReaderChanged', 'screenReaderChanged'], ['darkerSystemColorsChanged', 'darkerSystemColorsChanged'], + ['onOffSwitchLabelsChanged', 'onOffSwitchLabelsChanged'], ]); /** @@ -287,6 +289,38 @@ const AccessibilityInfo = { } }, + /** + * Query whether on/off switch labels are currently enabled. + * + * Returns a promise which resolves to a boolean. + * The result is `true` when on/off labels is enabled and `false` otherwise. + * + * @platform ios + */ + isOnOffSwitchLabelsEnabled(): Promise { + if (Platform.OS === 'android') { + return Promise.resolve(false); + } else { + return new Promise((resolve, reject) => { + if ( + NativeAccessibilityManagerIOS?.getCurrentOnOffSwitchLabelsState != + null + ) { + NativeAccessibilityManagerIOS.getCurrentOnOffSwitchLabelsState( + resolve, + reject, + ); + } else { + reject( + new Error( + 'NativeAccessibilityManagerIOS.getCurrentOnOffSwitchLabelsState is not available', + ), + ); + } + }); + } + }, + /** * Query whether reduced transparency is currently enabled. * @@ -407,6 +441,9 @@ const AccessibilityInfo = { * toggle changes. The argument to the event handler is a boolean. The boolean is `true` when * dark system colors is enabled and `false` otherwise. * + * - `onOffSwitchLabelsChanged`: iOS-only event. Fires when the state of the on/off labels toggle changes. + * The argument to the event handler is a boolean. + * * These events are only supported on Android: * * - `highTextContrastChanged`: Android-only event. Fires when the state of the high text contrast diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-onOffSwitchLabelsChanged-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-onOffSwitchLabelsChanged-test.js new file mode 100644 index 00000000000..67f1af2cccf --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-onOffSwitchLabelsChanged-test.js @@ -0,0 +1,72 @@ +/** + * 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 onOffSwitchLabelsChanged', () => { + it('delivers onOffSwitchLabelsChanged on iOS', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'onOffSwitchLabelsChanged', + handler, + ); + RCTDeviceEventEmitter.emit('onOffSwitchLabelsChanged', true); + subscription.remove(); + RCTDeviceEventEmitter.emit('onOffSwitchLabelsChanged', false); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(true); + }); + + it('does not subscribe on Android', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = + loadForPlatform('android'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'onOffSwitchLabelsChanged', + handler, + ); + RCTDeviceEventEmitter.emit('onOffSwitchLabelsChanged', 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 9e3d63ca330..5b9d08195be 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-test.js @@ -16,6 +16,9 @@ const mockGetCurrentPrefersCrossFadeTransitionsState = jest.fn( const mockGetCurrentDarkerSystemColorsState = jest.fn((onSuccess, onError) => onSuccess(true), ); +const mockGetCurrentOnOffSwitchLabelsState = jest.fn((onSuccess, onError) => + onSuccess(true), +); const mockGetCurrentBoldTextState = jest.fn((onSuccess, onError) => onSuccess(true), ); @@ -35,6 +38,13 @@ const mockGetCurrentVoiceOverState = jest.fn((onSuccess, onError) => onSuccess(true), ); let mockNativeAccessibilityManagerIOS: { + getCurrentOnOffSwitchLabelsState: JestMockFn< + [ + onSuccess: (isOnOffSwitchLabelsEnabled: boolean) => void, + onError: (error: Error) => void, + ], + void, + > | null, getCurrentPrefersCrossFadeTransitionsState: JestMockFn< [ onSuccess: (prefersCrossFadeTransitions: boolean) => void, @@ -92,6 +102,7 @@ let mockNativeAccessibilityManagerIOS: { void, > | null, } = { + getCurrentOnOffSwitchLabelsState: mockGetCurrentOnOffSwitchLabelsState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, getCurrentDarkerSystemColorsState: mockGetCurrentDarkerSystemColorsState, @@ -660,6 +671,50 @@ describe('AccessibilityInfo', () => { }); }); + describe('isOnOffSwitchLabelsEnabled', () => { + describe('Android', () => { + it('should return immediately', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const result = await AccessibilityInfo.isOnOffSwitchLabelsEnabled(); + + expect(result).toBe(false); + }); + }); + + describe('iOS', () => { + it('should call getCurrentOnOffSwitchLabelsState if available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + const result = await AccessibilityInfo.isOnOffSwitchLabelsEnabled(); + + expect(mockGetCurrentOnOffSwitchLabelsState).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should throw error if getCurrentOnOffSwitchLabelsState is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + mockNativeAccessibilityManagerIOS.getCurrentOnOffSwitchLabelsState = + null; + + const result: unknown = + await AccessibilityInfo.isOnOffSwitchLabelsEnabled().catch(e => e); + + invariant( + result instanceof Error, + 'Expected isOnOffSwitchLabelsEnabled to reject', + ); + expect(result.message).toEqual( + 'NativeAccessibilityManagerIOS.getCurrentOnOffSwitchLabelsState is not available', + ); + }); + }); + }); + describe('isDarkerSystemColorsEnabled', () => { describe('Android', () => { it('should return immediately', async () => { @@ -791,6 +846,7 @@ describe('AccessibilityInfo', () => { afterEach(() => { mockNativeAccessibilityManagerIOS = { + getCurrentOnOffSwitchLabelsState: mockGetCurrentOnOffSwitchLabelsState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, getCurrentDarkerSystemColorsState: mockGetCurrentDarkerSystemColorsState, diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.h b/packages/react-native/React/CoreModules/RCTAccessibilityManager.h index e8ea831b931..aa21f6d4b47 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.h +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.h @@ -27,6 +27,7 @@ extern NSString *const RCTAccessibilityManagerDidUpdateMultiplierNotification; / @property (nonatomic, assign) BOOL isDarkerSystemColorsEnabled; @property (nonatomic, assign) BOOL prefersCrossFadeTransitions; @property (nonatomic, assign) BOOL isReduceTransparencyEnabled; +@property (nonatomic, assign) BOOL isOnOffSwitchLabelsEnabled; @property (nonatomic, assign) BOOL isVoiceOverEnabled; @end diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm index 7ff290fdce8..df59d54aea0 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm @@ -91,6 +91,11 @@ - (instancetype)init name:UIAccessibilityReduceTransparencyStatusDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onOffSwitchLabelsStatusDidChange:) + name:UIAccessibilityOnOffSwitchLabelsDidChangeNotification + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(voiceVoiceOverStatusDidChange:) name:UIAccessibilityVoiceOverStatusDidChangeNotification @@ -103,6 +108,7 @@ - (instancetype)init _isReduceMotionEnabled = UIAccessibilityIsReduceMotionEnabled(); _isDarkerSystemColorsEnabled = UIAccessibilityDarkerSystemColorsEnabled(); _isReduceTransparencyEnabled = UIAccessibilityIsReduceTransparencyEnabled(); + _isOnOffSwitchLabelsEnabled = UIAccessibilityIsOnOffSwitchLabelsEnabled(); _isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); } return self; @@ -207,6 +213,19 @@ - (void)reduceTransparencyStatusDidChange:(__unused NSNotification *)notificatio } } +- (void)onOffSwitchLabelsStatusDidChange:(__unused NSNotification *)notification +{ + BOOL newValue = UIAccessibilityIsOnOffSwitchLabelsEnabled(); + if (_isOnOffSwitchLabelsEnabled != newValue) { + _isOnOffSwitchLabelsEnabled = newValue; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"onOffSwitchLabelsChanged" + body:@(_isOnOffSwitchLabelsEnabled)]; +#pragma clang diagnostic pop + } +} + - (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification { BOOL isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); @@ -411,6 +430,12 @@ - (void)getCurrentReduceTransparencyState:(RCTResponseSenderBlock)onSuccess onSuccess(@[ @(_isReduceTransparencyEnabled) ]); } +- (void)getCurrentOnOffSwitchLabelsState:(RCTResponseSenderBlock)onSuccess + onError:(__unused RCTResponseSenderBlock)onError +{ + onSuccess(@[ @(_isOnOffSwitchLabelsEnabled) ]); +} + - (void)getCurrentVoiceOverState:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError { onSuccess(@[ @(_isVoiceOverEnabled) ]); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff..5cf469c0469 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<<6ba8b0e6124573d6ae3d3ee69f9cd742>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -98,6 +98,7 @@ declare const AccessibilityInfo_default: { isGrayscaleEnabled(): Promise isHighTextContrastEnabled(): Promise isInvertColorsEnabled(): Promise + isOnOffSwitchLabelsEnabled(): Promise isReduceMotionEnabled(): Promise isReduceTransparencyEnabled(): Promise isScreenReaderEnabled(): Promise @@ -973,6 +974,7 @@ declare type AccessibilityEventDefinitionsIOS = { ] boldTextChanged: [boolean] darkerSystemColorsChanged: [boolean] + onOffSwitchLabelsChanged: [boolean] reduceTransparencyChanged: [boolean] } declare type AccessibilityEventTypes = @@ -5770,7 +5772,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // d92c5eae + AccessibilityInfo, // fe2c84fb AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694b..a85410f5b18 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -1464,6 +1464,12 @@ class AccessibilityTest extends React.Component { } } +AccessibilityInfo.isOnOffSwitchLabelsEnabled().then(isEnabled => + console.log(`AccessibilityInfo.isOnOffSwitchLabelsEnabled => ${isEnabled}`), +); +AccessibilityInfo.addEventListener('onOffSwitchLabelsChanged', isEnabled => + console.log(`AccessibilityInfo.onOffSwitchLabelsChanged => ${isEnabled}`), +); AccessibilityInfo.isBoldTextEnabled().then(isEnabled => console.log(`AccessibilityInfo.isBoldTextEnabled => ${isEnabled}`), ); 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 f30e52bc0b2..10dbaff5814 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAccessibilityManager.js @@ -38,6 +38,10 @@ export interface Spec extends TurboModule { onSuccess: (prefersCrossFadeTransitions: boolean) => void, onError: (error: UnsafeObject) => void, ) => void; + readonly getCurrentOnOffSwitchLabelsState?: ( + onSuccess: (isOnOffSwitchLabelsEnabled: 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 80e5fe6a329..d7dcb96bcd9 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 @@ -26,7 +26,9 @@ type AccessibilityChangeEventName = | 'reduceMotionChanged' /** iOS only */ | 'reduceTransparencyChanged' - | 'screenReaderChanged'; + | 'screenReaderChanged' + /** iOS only */ + | 'onOffSwitchLabelsChanged'; type AccessibilityChangeEvent = boolean; @@ -86,6 +88,13 @@ export interface AccessibilityInfoStatic { */ isHighTextContrastEnabled: () => Promise; + /** + * Query whether on/off switch labels are currently enabled. + * + * @platform ios + */ + isOnOffSwitchLabelsEnabled: () => Promise; + /** * Query whether darker system colors is currently enabled. * diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index 2ac5879c945..6951f22e2ad 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -1594,6 +1594,11 @@ class DisplayOptionsStatusExample extends React.Component<{}> { optionChecker={AccessibilityInfo.isInvertColorsEnabled} notification={'invertColorsChanged'} /> +