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 4dc215bcabd7..48fef70011a7 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 b922c77f0626..dcccf52756d1 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 000000000000..67f1af2cccf1 --- /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 9e3d63ca330d..5b9d08195be9 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 e8ea831b931b..aa21f6d4b47d 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 7ff290fdce80..df59d54aea03 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 74c407136ff1..5cf469c04690 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 54ea8b7694ba..a85410f5b188 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 f30e52bc0b2f..10dbaff5814d 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 80e5fe6a3295..d7dcb96bcd96 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 2ac5879c9458..6951f22e2adc 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'} /> +