Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/jest-preset/jest/mocks/AccessibilityInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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<AccessibilityPreferences>>,
isShowBordersEnabled: jest.fn(() => Promise.resolve(false)) as JestMockFn<
$FlowFixMe,
$FlowFixMe,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -29,6 +50,7 @@ type AccessibilityEventDefinitionsIOS = {
announcementFinished: [{announcement: string, success: boolean}],
boldTextChanged: [boolean],
darkerSystemColorsChanged: [boolean],
preferencesChanged: [AccessibilityPreferences],
reduceTransparencyChanged: [boolean],
showBordersChanged: [boolean],
};
Expand Down Expand Up @@ -69,6 +91,7 @@ const EventNames: Map<keyof AccessibilityEventDefinitions, string> =
['screenReaderChanged', 'screenReaderChanged'],
['darkerSystemColorsChanged', 'darkerSystemColorsChanged'],
['showBordersChanged', 'showBordersChanged'],
['preferencesChanged', 'accessibilityPreferencesChanged'],
]);

/**
Expand Down Expand Up @@ -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<AccessibilityPreferences> {
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.
*
Expand Down Expand Up @@ -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:
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand All @@ -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,
Expand Down Expand Up @@ -102,6 +116,7 @@ let mockNativeAccessibilityManagerIOS: {
void,
> | null,
} = {
getCurrentPreferences: mockGetCurrentPreferences,
getCurrentShowBordersState: mockGetCurrentShowBordersState,
getCurrentPrefersCrossFadeTransitionsState:
mockGetCurrentPrefersCrossFadeTransitionsState,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString *, id> *_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
Loading
Loading