diff --git a/packages/jest-preset/jest/mocks/AccessibilityInfo.js b/packages/jest-preset/jest/mocks/AccessibilityInfo.js index 4dc215bcabd7..d49f63d51ebf 100644 --- a/packages/jest-preset/jest/mocks/AccessibilityInfo.js +++ b/packages/jest-preset/jest/mocks/AccessibilityInfo.js @@ -20,6 +20,10 @@ const AccessibilityInfo = { isAccessibilityServiceEnabled: jest.fn(() => Promise.resolve(false), ) as JestMockFn<$FlowFixMe, $FlowFixMe>, + isShowBordersEnabled: 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..017c1d3c842e 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], + showBordersChanged: [boolean], }; type AccessibilityEventDefinitions = { @@ -67,6 +68,7 @@ const EventNames: Map = ['reduceTransparencyChanged', 'reduceTransparencyChanged'], ['screenReaderChanged', 'screenReaderChanged'], ['darkerSystemColorsChanged', 'darkerSystemColorsChanged'], + ['showBordersChanged', 'showBordersChanged'], ]); /** @@ -287,6 +289,36 @@ const AccessibilityInfo = { } }, + /** + * Query whether Show Borders (called Button Shapes before iOS 26.1) is + * currently enabled. + * + * Returns a promise which resolves to a boolean. + * The result is `true` when show borders is enabled and `false` otherwise. + * + * @platform ios + */ + isShowBordersEnabled(): Promise { + if (Platform.OS === 'android') { + return Promise.resolve(false); + } else { + return new Promise((resolve, reject) => { + if (NativeAccessibilityManagerIOS?.getCurrentShowBordersState != null) { + NativeAccessibilityManagerIOS.getCurrentShowBordersState( + resolve, + reject, + ); + } else { + reject( + new Error( + 'NativeAccessibilityManagerIOS.getCurrentShowBordersState is not available', + ), + ); + } + }); + } + }, + /** * Query whether reduced transparency is currently enabled. * @@ -407,6 +439,10 @@ 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. * + * - `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. + * * 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-showBordersChanged-test.js b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-showBordersChanged-test.js new file mode 100644 index 000000000000..530950b9be72 --- /dev/null +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/__tests__/AccessibilityInfo-showBordersChanged-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 showBordersChanged', () => { + it('delivers showBordersChanged on iOS', () => { + const {AccessibilityInfo, RCTDeviceEventEmitter} = loadForPlatform('ios'); + const handler = jest.fn(); + + const subscription = AccessibilityInfo.addEventListener( + 'showBordersChanged', + handler, + ); + RCTDeviceEventEmitter.emit('showBordersChanged', true); + subscription.remove(); + RCTDeviceEventEmitter.emit('showBordersChanged', 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( + 'showBordersChanged', + handler, + ); + RCTDeviceEventEmitter.emit('showBordersChanged', 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..380be9fdd394 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 mockGetCurrentShowBordersState = 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: { + getCurrentShowBordersState: JestMockFn< + [ + onSuccess: (isShowBordersEnabled: boolean) => void, + onError: (error: Error) => void, + ], + void, + > | null, getCurrentPrefersCrossFadeTransitionsState: JestMockFn< [ onSuccess: (prefersCrossFadeTransitions: boolean) => void, @@ -92,6 +102,7 @@ let mockNativeAccessibilityManagerIOS: { void, > | null, } = { + getCurrentShowBordersState: mockGetCurrentShowBordersState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, getCurrentDarkerSystemColorsState: mockGetCurrentDarkerSystemColorsState, @@ -660,6 +671,49 @@ describe('AccessibilityInfo', () => { }); }); + describe('isShowBordersEnabled', () => { + describe('Android', () => { + it('should return immediately', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'android'; + + const result = await AccessibilityInfo.isShowBordersEnabled(); + + expect(result).toBe(false); + }); + }); + + describe('iOS', () => { + it('should call getCurrentShowBordersState if available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + const result = await AccessibilityInfo.isShowBordersEnabled(); + + expect(mockGetCurrentShowBordersState).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should throw error if getCurrentShowBordersState is not available', async () => { + /* $FlowFixMe[incompatible-type] */ + Platform.OS = 'ios'; + + mockNativeAccessibilityManagerIOS.getCurrentShowBordersState = null; + + const result: unknown = + await AccessibilityInfo.isShowBordersEnabled().catch(e => e); + + invariant( + result instanceof Error, + 'Expected isShowBordersEnabled to reject', + ); + expect(result.message).toEqual( + 'NativeAccessibilityManagerIOS.getCurrentShowBordersState is not available', + ); + }); + }); + }); + describe('isDarkerSystemColorsEnabled', () => { describe('Android', () => { it('should return immediately', async () => { @@ -791,6 +845,7 @@ describe('AccessibilityInfo', () => { afterEach(() => { mockNativeAccessibilityManagerIOS = { + getCurrentShowBordersState: mockGetCurrentShowBordersState, getCurrentPrefersCrossFadeTransitionsState: mockGetCurrentPrefersCrossFadeTransitionsState, getCurrentDarkerSystemColorsState: mockGetCurrentDarkerSystemColorsState, diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index da76a10ac812..c41bcba02903 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -416,6 +416,7 @@ let reactCoreRCTWebsocket = RNTarget( let reactCoreModules = RNTarget( name: .reactCoreModules, path: "React/CoreModules", + linkedFrameworks: ["Accessibility"], excludedPaths: ["PlatformStubs/RCTStatusBarManager.mm"], dependencies: [.reactNativeDependencies, .jsi, .yoga, .reactTurboModuleCore, .reactFeatureFlags] ) diff --git a/packages/react-native/React/CoreModules/RCTAccessibilityManager.h b/packages/react-native/React/CoreModules/RCTAccessibilityManager.h index e8ea831b931b..e2063e67c885 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 isShowBordersEnabled; @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..e9fb974986b5 100644 --- a/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm +++ b/packages/react-native/React/CoreModules/RCTAccessibilityManager.mm @@ -17,6 +17,8 @@ #import "CoreModulesPlugins.h" +#import + NSString *const RCTAccessibilityManagerDidUpdateMultiplierNotification = @"RCTAccessibilityManagerDidUpdateMultiplierNotification"; @@ -27,6 +29,34 @@ @interface RCTAccessibilityManager () @end +// Show Borders was called Button Shapes before iOS 26.1, where UIKit deprecated +// its API in favor of the Accessibility framework. +static BOOL RCTIsShowBordersEnabled(void) +{ +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 260100 + if (@available(iOS 26.1, *)) { + return AXShowBordersEnabled(); + } +#endif +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + return UIAccessibilityButtonShapesEnabled(); +#pragma clang diagnostic pop +} + +static NSNotificationName RCTShowBordersStatusDidChangeNotification(void) +{ +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 260100 + if (@available(iOS 26.1, *)) { + return AXShowBordersEnabledStatusDidChangeNotification; + } +#endif +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + return UIAccessibilityButtonShapesEnabledStatusDidChangeNotification; +#pragma clang diagnostic pop +} + @implementation RCTAccessibilityManager @synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; @@ -91,6 +121,11 @@ - (instancetype)init name:UIAccessibilityReduceTransparencyStatusDidChangeNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(showBordersStatusDidChange:) + name:RCTShowBordersStatusDidChangeNotification() + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(voiceVoiceOverStatusDidChange:) name:UIAccessibilityVoiceOverStatusDidChangeNotification @@ -103,6 +138,7 @@ - (instancetype)init _isReduceMotionEnabled = UIAccessibilityIsReduceMotionEnabled(); _isDarkerSystemColorsEnabled = UIAccessibilityDarkerSystemColorsEnabled(); _isReduceTransparencyEnabled = UIAccessibilityIsReduceTransparencyEnabled(); + _isShowBordersEnabled = RCTIsShowBordersEnabled(); _isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); } return self; @@ -207,6 +243,19 @@ - (void)reduceTransparencyStatusDidChange:(__unused NSNotification *)notificatio } } +- (void)showBordersStatusDidChange:(__unused NSNotification *)notification +{ + BOOL newShowBordersEnabled = RCTIsShowBordersEnabled(); + if (_isShowBordersEnabled != newShowBordersEnabled) { + _isShowBordersEnabled = newShowBordersEnabled; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [[_moduleRegistry moduleForName:"EventDispatcher"] sendDeviceEventWithName:@"showBordersChanged" + body:@(_isShowBordersEnabled)]; +#pragma clang diagnostic pop + } +} + - (void)voiceVoiceOverStatusDidChange:(__unused NSNotification *)notification { BOOL isVoiceOverEnabled = UIAccessibilityIsVoiceOverRunning(); @@ -411,6 +460,11 @@ - (void)getCurrentReduceTransparencyState:(RCTResponseSenderBlock)onSuccess onSuccess(@[ @(_isReduceTransparencyEnabled) ]); } +- (void)getCurrentShowBordersState:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError +{ + onSuccess(@[ @(_isShowBordersEnabled) ]); +} + - (void)getCurrentVoiceOverState:(RCTResponseSenderBlock)onSuccess onError:(__unused RCTResponseSenderBlock)onError { onSuccess(@[ @(_isVoiceOverEnabled) ]); diff --git a/packages/react-native/React/CoreModules/React-CoreModules.podspec b/packages/react-native/React/CoreModules/React-CoreModules.podspec index 215fee2e474a..0de758e808f6 100644 --- a/packages/react-native/React/CoreModules/React-CoreModules.podspec +++ b/packages/react-native/React/CoreModules/React-CoreModules.podspec @@ -46,7 +46,7 @@ Pod::Spec.new do |s| "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "HEADER_SEARCH_PATHS" => header_search_paths.join(" ") } - s.framework = "UIKit" + s.frameworks = ["UIKit", "Accessibility"] s.dependency "RCTTypeSafety", version s.dependency "React-Core/CoreModulesHeaders", version s.dependency "React-RCTImage", version diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff1..9cc8048a3e74 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<<09cb4ee90e2e29b0924c0359d0ebef39>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -101,6 +101,7 @@ declare const AccessibilityInfo_default: { isReduceMotionEnabled(): Promise isReduceTransparencyEnabled(): Promise isScreenReaderEnabled(): Promise + isShowBordersEnabled(): Promise prefersCrossFadeTransitions(): Promise sendAccessibilityEvent( handle: HostInstance, @@ -974,6 +975,7 @@ declare type AccessibilityEventDefinitionsIOS = { boldTextChanged: [boolean] darkerSystemColorsChanged: [boolean] reduceTransparencyChanged: [boolean] + showBordersChanged: [boolean] } declare type AccessibilityEventTypes = "click" | "focus" | "viewHoverEnter" | "windowStateChange" @@ -5770,7 +5772,7 @@ declare type WrapperComponentProvider = ( export { AccessibilityActionEvent, // a276ec6e AccessibilityActionInfo, // c94545a7 - AccessibilityInfo, // d92c5eae + AccessibilityInfo, // 7d8718ed AccessibilityProps, // 95dbb1d8 AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694ba..75e5dce1634c 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.isShowBordersEnabled().then(isEnabled => + console.log(`AccessibilityInfo.isShowBordersEnabled => ${isEnabled}`), +); +AccessibilityInfo.addEventListener('showBordersChanged', isEnabled => + console.log(`AccessibilityInfo.showBordersChanged => ${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..0dfb462fe674 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 getCurrentShowBordersState?: ( + onSuccess: (isShowBordersEnabled: 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..6f2ccb478328 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 */ + | 'showBordersChanged'; type AccessibilityChangeEvent = boolean; @@ -86,6 +88,13 @@ export interface AccessibilityInfoStatic { */ isHighTextContrastEnabled: () => Promise; + /** + * Query whether Show Borders (called Button Shapes before iOS 26.1) is currently enabled. + * + * @platform ios + */ + isShowBordersEnabled: () => 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..ff2891104139 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'} /> +