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
6 changes: 6 additions & 0 deletions packages/jest-preset/jest/mocks/AccessibilityInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const AccessibilityInfo = {
isAccessibilityServiceEnabled: jest.fn(() =>
Promise.resolve(false),
) as JestMockFn<$FlowFixMe, $FlowFixMe>,
getPreferences: jest.fn(() =>
Promise.resolve({
prefersSwitchLabels: false,
prefersNonColorIndicators: false,
}),
) as JestMockFn<$FlowFixMe, $FlowFixMe>,
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,24 @@ 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.
*/
export type AccessibilityPreferences = {
// 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 +47,7 @@ type AccessibilityEventDefinitionsIOS = {
announcementFinished: [{announcement: string, success: boolean}],
boldTextChanged: [boolean],
darkerSystemColorsChanged: [boolean],
preferencesChanged: [AccessibilityPreferences],
reduceTransparencyChanged: [boolean],
showBordersChanged: [boolean],
};
Expand Down Expand Up @@ -69,6 +88,7 @@ const EventNames: Map<keyof AccessibilityEventDefinitions, string> =
['screenReaderChanged', 'screenReaderChanged'],
['darkerSystemColorsChanged', 'darkerSystemColorsChanged'],
['showBordersChanged', 'showBordersChanged'],
['preferencesChanged', 'preferencesChanged'],
]);

/**
Expand Down Expand Up @@ -319,6 +339,27 @@ 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`.
*/
getPreferences(): Promise<AccessibilityPreferences> {
if (Platform.OS === 'android') {
return Promise.resolve({...NEUTRAL_PREFERENCES});
} else {
return new Promise((resolve, reject) => {
if (NativeAccessibilityManagerIOS?.getCurrentPreferences != null) {
NativeAccessibilityManagerIOS.getCurrentPreferences(resolve, reject);
} else {
resolve({...NEUTRAL_PREFERENCES});
}
});
}
},

/**
* Query whether reduced transparency is currently enabled.
*
Expand Down Expand Up @@ -442,6 +483,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 Expand Up @@ -528,10 +571,13 @@ const AccessibilityInfo = {
},

/**
* Get the recommended timeout in milliseconds the user needs, as specified
* in the device's Accessibility settings.
* Get the recommended timeout in milliseconds the user needs, given the
* timeout the app would otherwise use.
*
* @platform android
* On Android, this follows the "Time to take action" accessibility setting.
* On iOS, it is three times `originalTimeout` while Switch Control is running,
* because moving between elements one switch press at a time is slow, and
* `originalTimeout` otherwise.
*/
getRecommendedTimeoutMillis(originalTimeout: number): Promise<number> {
if (Platform.OS === 'android') {
Expand All @@ -548,7 +594,18 @@ const AccessibilityInfo = {
}
});
} else {
return Promise.resolve(originalTimeout);
return new Promise(resolve => {
if (
NativeAccessibilityManagerIOS?.getRecommendedTimeoutMillis != null
) {
NativeAccessibilityManagerIOS.getRecommendedTimeoutMillis(
originalTimeout,
resolve,
);
} else {
resolve(originalTimeout);
}
});
}
},
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* 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('preferencesChanged', preferences);
subscription.remove();
RCTDeviceEventEmitter.emit('preferencesChanged', {
prefersSwitchLabels: false,
prefersNonColorIndicators: false,
});

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(preferences);
});

it('does not subscribe on Android', () => {
const {AccessibilityInfo, RCTDeviceEventEmitter} =
loadForPlatform('android');
const handler = jest.fn();

const subscription = AccessibilityInfo.addEventListener(
'preferencesChanged',
handler,
);
RCTDeviceEventEmitter.emit('preferencesChanged', {
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,17 @@ const mockGetCurrentShowBordersState = jest.fn((onSuccess, onError) =>
const mockGetCurrentBoldTextState = jest.fn((onSuccess, onError) =>
onSuccess(true),
);
const IOS_PREFERENCES = {
prefersSwitchLabels: true,
prefersNonColorIndicators: false,
};
const mockGetCurrentPreferences = jest.fn((onSuccess, onError) =>
onSuccess(IOS_PREFERENCES),
);
const IOS_RECOMMENDED_TIMEOUT = 15000;
const mockGetRecommendedTimeoutMillisIOS = jest.fn(
(originalTimeout, onSuccess) => onSuccess(IOS_RECOMMENDED_TIMEOUT),
);
const mockGetCurrentGrayscaleState = jest.fn((onSuccess, onError) =>
onSuccess(true),
);
Expand All @@ -38,6 +49,20 @@ const mockGetCurrentVoiceOverState = jest.fn((onSuccess, onError) =>
onSuccess(true),
);
let mockNativeAccessibilityManagerIOS: {
getCurrentPreferences: JestMockFn<
[
onSuccess: (preferences: {
prefersSwitchLabels: boolean,
prefersNonColorIndicators: boolean,
}) => void,
onError: (error: Error) => void,
],
void,
> | null,
getRecommendedTimeoutMillis: JestMockFn<
[originalTimeout: number, onSuccess: (timeout: number) => void],
void,
> | null,
getCurrentShowBordersState: JestMockFn<
[
onSuccess: (isShowBordersEnabled: boolean) => void,
Expand Down Expand Up @@ -102,6 +127,8 @@ let mockNativeAccessibilityManagerIOS: {
void,
> | null,
} = {
getCurrentPreferences: mockGetCurrentPreferences,
getRecommendedTimeoutMillis: mockGetRecommendedTimeoutMillisIOS,
getCurrentShowBordersState: mockGetCurrentShowBordersState,
getCurrentPrefersCrossFadeTransitionsState:
mockGetCurrentPrefersCrossFadeTransitionsState,
Expand Down Expand Up @@ -831,10 +858,26 @@ describe('AccessibilityInfo', () => {
});

describe('iOS', () => {
it('should return originalTimeout', async () => {
it('should return the native recommended timeout', async () => {
/* $FlowFixMe[incompatible-type] */
Platform.OS = 'ios';

const recommendedTimeoutMillis =
await AccessibilityInfo.getRecommendedTimeoutMillis(5000);

expect(mockGetRecommendedTimeoutMillisIOS).toHaveBeenCalledWith(
5000,
expect.any(Function),
);
expect(recommendedTimeoutMillis).toBe(IOS_RECOMMENDED_TIMEOUT);
});

it('should return originalTimeout if getRecommendedTimeoutMillis is not available', async () => {
/* $FlowFixMe[incompatible-type] */
Platform.OS = 'ios';

mockNativeAccessibilityManagerIOS.getRecommendedTimeoutMillis = null;

const recommendedTimeoutMillis =
await AccessibilityInfo.getRecommendedTimeoutMillis(5000);

Expand All @@ -843,8 +886,52 @@ describe('AccessibilityInfo', () => {
});
});

describe('getPreferences', () => {
describe('Android', () => {
it('should return neutral preferences immediately', async () => {
/* $FlowFixMe[incompatible-type] */
Platform.OS = 'android';

const preferences = await AccessibilityInfo.getPreferences();

expect(preferences).toEqual({
prefersSwitchLabels: false,
prefersNonColorIndicators: false,
});
});
});

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 return neutral preferences if getCurrentPreferences is not available', async () => {
/* $FlowFixMe[incompatible-type] */
Platform.OS = 'ios';

mockNativeAccessibilityManagerIOS.getCurrentPreferences = null;

const preferences = await AccessibilityInfo.getPreferences();

expect(preferences).toEqual({
prefersSwitchLabels: false,
prefersNonColorIndicators: false,
});
});
});
});

afterEach(() => {
mockNativeAccessibilityManagerIOS = {
getCurrentPreferences: mockGetCurrentPreferences,
getRecommendedTimeoutMillis: mockGetRecommendedTimeoutMillisIOS,
getCurrentShowBordersState: mockGetCurrentShowBordersState,
getCurrentPrefersCrossFadeTransitionsState:
mockGetCurrentPrefersCrossFadeTransitionsState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ void RCTAccessibilityManagerSetIsVoiceOverEnabled(
RCTAccessibilityManager *accessibilityManager,
BOOL isVoiceOverEnabled);

typedef NSDictionary<NSString *, NSNumber *> *_Nonnull (^RCTAccessibilityPreferencesReader)(void);
typedef BOOL (^RCTSwitchControlReader)(void);

// Replaces how the module reads the system settings behind getPreferences()
// and getRecommendedTimeoutMillis. Pass nil to read the system again.
void RCTAccessibilityManagerSetSystemReaders(
RCTAccessibilityPreferencesReader _Nullable preferencesReader,
RCTSwitchControlReader _Nullable switchControlReader);

RCT_EXTERN_C_END

NS_ASSUME_NONNULL_END
Loading
Loading