@react-native-ama/core 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ import { act, render } from '@testing-library/react-native';
2
+ import { flushMicroTasks } from '@testing-library/react-native/build/flush-micro-tasks';
3
+ import * as React from 'react';
4
+ import { Pressable, Text } from 'react-native';
5
+
6
+ import { AMAProvider } from './AMAProvider';
7
+ import { HideChildrenFromAccessibilityTree } from './HideChildrenFromAccessibilityTree';
8
+
9
+ beforeEach(() => {
10
+ jest.clearAllMocks();
11
+ });
12
+
13
+ describe('HideChildrenFromAccessibilityTree', () => {
14
+ describe('iOS', () => {
15
+ beforeEach(() => {
16
+ const Platform = jest.requireActual(
17
+ 'react-native/Libraries/Utilities/Platform',
18
+ );
19
+
20
+ Platform.select = (params: any) => {
21
+ return params.ios;
22
+ };
23
+ });
24
+
25
+ it('hides all the children from the accessibility tree when the screen reader is enabled', async () => {
26
+ const { getByTestId } = await renderHideChildrenFromAccessibilityTree();
27
+
28
+ ['test-text', 'test-button', 'nested-text'].forEach(testID => {
29
+ expect(getByTestId(testID).props).toEqual(
30
+ expect.objectContaining({
31
+ importantForAccessibility: 'no',
32
+ accessibilityElementsHidden: true,
33
+ }),
34
+ );
35
+ });
36
+ });
37
+
38
+ it('does nothing if the SR is turned off %s', async () => {
39
+ const { getByTestId } = await renderHideChildrenFromAccessibilityTree({
40
+ isScreenReaderEnabled: false,
41
+ });
42
+
43
+ ['test-text', 'test-button', 'nested-text'].forEach(testID => {
44
+ expect(getByTestId(testID).props).not.toHaveProperty(
45
+ 'importantForAccessibility',
46
+ );
47
+
48
+ expect(getByTestId(testID).props).not.toHaveProperty(
49
+ 'accessibilityElementsHidden',
50
+ );
51
+ });
52
+ });
53
+ });
54
+
55
+ describe('Android', () => {
56
+ beforeEach(() => {
57
+ const Platform = jest.requireActual(
58
+ 'react-native/Libraries/Utilities/Platform',
59
+ );
60
+
61
+ Platform.select = (params: any) => {
62
+ return params.default;
63
+ };
64
+ });
65
+
66
+ it('hides all the children from the accessibility tree when the screen reader is enabled', async () => {
67
+ const { getByTestId } = await renderHideChildrenFromAccessibilityTree();
68
+
69
+ expect(getByTestId('test-wrapper').props).toEqual(
70
+ expect.objectContaining({
71
+ importantForAccessibility: 'no-hide-descendants',
72
+ }),
73
+ );
74
+ });
75
+
76
+ it('does nothing if the SR is turned off %s', async () => {
77
+ const { queryByTestId } = await renderHideChildrenFromAccessibilityTree({
78
+ isScreenReaderEnabled: false,
79
+ });
80
+
81
+ expect(queryByTestId('test-wrapper')).toBeNull();
82
+ });
83
+ });
84
+ });
85
+
86
+ const renderHideChildrenFromAccessibilityTree = async (params?: {
87
+ isScreenReaderEnabled: boolean;
88
+ }) => {
89
+ useAMAContext.mockReturnValue({
90
+ isScreenReaderEnabled: params?.isScreenReaderEnabled ?? true,
91
+ });
92
+
93
+ const renderAPI = render(
94
+ <AMAProvider>
95
+ <HideChildrenFromAccessibilityTree testID="test-wrapper">
96
+ <Text testID="test-text">Test here</Text>
97
+ <>
98
+ <Pressable testID="test-button">
99
+ <Text testID="nested-text">Press me</Text>
100
+ </Pressable>
101
+ </>
102
+ </HideChildrenFromAccessibilityTree>
103
+ </AMAProvider>,
104
+ );
105
+
106
+ await act(async () => {
107
+ await flushMicroTasks();
108
+ });
109
+
110
+ return renderAPI;
111
+ };
112
+
113
+ let useAMAContext: jest.Mock;
114
+
115
+ function mockAMAProvider() {
116
+ const originalModule = jest.requireActual('./AMAProvider');
117
+
118
+ useAMAContext = jest.fn().mockReturnValue({
119
+ isScreenReaderEnabled: true,
120
+ });
121
+
122
+ return {
123
+ ...originalModule,
124
+ useAMAContext,
125
+ };
126
+ }
127
+
128
+ jest.mock('./AMAProvider', () => mockAMAProvider());
@@ -0,0 +1,55 @@
1
+ import React, { PropsWithChildren } from 'react';
2
+ import { Platform, View } from 'react-native';
3
+
4
+ import { useAMAContext } from './AMAProvider';
5
+
6
+ type HideChildrenFromAccessibilityTreeProps = {
7
+ testID?: string;
8
+ };
9
+
10
+ export const HideChildrenFromAccessibilityTree = (
11
+ props: PropsWithChildren<HideChildrenFromAccessibilityTreeProps>,
12
+ ): JSX.Element => {
13
+ const { isScreenReaderEnabled } = useAMAContext();
14
+
15
+ if (!isScreenReaderEnabled) {
16
+ return <>{props.children}</>;
17
+ }
18
+
19
+ return Platform.select({
20
+ default: <HideChildrenFromAccessibilityAndroid {...props} />,
21
+ ios: <HideChildrenFromAccessibilityTreeIOS {...props} />,
22
+ });
23
+ };
24
+
25
+ const HideChildrenFromAccessibilityAndroid = ({
26
+ children,
27
+ testID,
28
+ }: PropsWithChildren<HideChildrenFromAccessibilityTreeProps>) => {
29
+ return (
30
+ <View importantForAccessibility="no-hide-descendants" testID={testID}>
31
+ {children}
32
+ </View>
33
+ );
34
+ };
35
+
36
+ const HideChildrenFromAccessibilityTreeIOS = ({
37
+ children,
38
+ }: PropsWithChildren<{}>) => {
39
+ return hideChildrenFromAccessibilityTree(children);
40
+ };
41
+
42
+ const hideChildrenFromAccessibilityTree = (component: React.ReactNode): any => {
43
+ return (
44
+ React.Children.map(component, child => {
45
+ return React.isValidElement(child)
46
+ ? React.cloneElement(child, {
47
+ // @ts-ignore
48
+ importantForAccessibility: 'no',
49
+ accessibilityElementsHidden: true,
50
+ children: hideChildrenFromAccessibilityTree(child.props.children),
51
+ })
52
+ : child;
53
+ }) || null
54
+ );
55
+ };
@@ -0,0 +1,73 @@
1
+ import { applyStyle } from '@react-native-ama/internal';
2
+ import type React from 'react';
3
+ import type { PressableStateCallbackType } from 'react-native';
4
+
5
+ import { useChecks } from './useChecks';
6
+
7
+ export const useButtonChecks = __DEV__
8
+ ? (
9
+ props: Record<string, any>,
10
+ children?:
11
+ | React.ReactNode
12
+ | ((state: PressableStateCallbackType) => React.ReactNode)
13
+ | undefined,
14
+ shouldPerformContrastCheck: boolean = true,
15
+ ) => {
16
+ const checks = useChecks();
17
+ if (checks === null) {
18
+ return null;
19
+ }
20
+ const {
21
+ noUndefinedProperty,
22
+ contrastChecker,
23
+ onLayout,
24
+ noUppercaseStringChecker,
25
+ checkCompatibleAccessibilityState,
26
+ checkAccessibilityRole,
27
+ debugStyle,
28
+ } = checks;
29
+
30
+ let style = props.style || {};
31
+
32
+ const isAccessible = props.accessible !== false;
33
+
34
+ isAccessible &&
35
+ noUndefinedProperty({
36
+ properties: props,
37
+ property: 'accessibilityRole',
38
+ rule: 'NO_ACCESSIBILITY_ROLE',
39
+ });
40
+ isAccessible &&
41
+ noUndefinedProperty({
42
+ properties: props,
43
+ property: 'accessibilityLabel',
44
+ rule: 'NO_ACCESSIBILITY_LABEL',
45
+ });
46
+ isAccessible &&
47
+ noUppercaseStringChecker({
48
+ text: props.accessibilityLabel,
49
+ });
50
+ checkCompatibleAccessibilityState({
51
+ accessibilityStates: props?.accessibilityState,
52
+ accessibilityRole: props?.accessiblityRole,
53
+ });
54
+ checkAccessibilityRole(props.accessibilityRole);
55
+
56
+ const contrastCheckerCallback = shouldPerformContrastCheck
57
+ ? contrastChecker
58
+ : undefined;
59
+
60
+ const theStyle = applyStyle?.({
61
+ style,
62
+ debugStyle,
63
+ // @ts-ignore
64
+ children,
65
+ contrastCheckerCallback,
66
+ });
67
+
68
+ return {
69
+ onLayout,
70
+ debugStyle: theStyle,
71
+ };
72
+ }
73
+ : null;
@@ -0,0 +1,214 @@
1
+ import * as Logger from '@react-native-ama/internal';
2
+ import { type RuleAction } from '@react-native-ama/internal';
3
+ import * as CheckForAccessibilityRole from '@react-native-ama/internal/src/checks/checkAccessibilityRole';
4
+ import * as CheckForAccessibilityState from '@react-native-ama/internal/src/checks/checkForAccessibilityState';
5
+ import { renderHook } from '@testing-library/react-native';
6
+
7
+ import * as AMAProvider from '../components/AMAProvider';
8
+ import { useChecks } from './useChecks';
9
+
10
+ const trackError = jest.fn();
11
+ const removeError = jest.fn();
12
+
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+
16
+ jest.spyOn(console, 'warn').mockImplementation();
17
+ jest.spyOn(console, 'error').mockImplementation();
18
+ jest.spyOn(console, 'info').mockImplementation();
19
+
20
+ jest.spyOn(AMAProvider, 'useAMAContext').mockReturnValue({
21
+ trackError,
22
+ removeError,
23
+ } as any);
24
+ });
25
+
26
+ describe('useChecks', () => {
27
+ it.each(['MUST_NOT', 'MUST'])(
28
+ 'tracks the error only if the rule is %s',
29
+ ruleValue => {
30
+ jest
31
+ // @ts-ignore
32
+ .spyOn(Logger, 'getRuleAction')
33
+ // @ts-ignore
34
+ .mockReturnValue(ruleValue as RuleAction);
35
+
36
+ const { result } = renderHook(() => useChecks());
37
+
38
+ result.current?.logResult('test-me', {
39
+ rule: 'NO_UNDEFINED',
40
+ extra: 'extra',
41
+ message: 'message',
42
+ });
43
+
44
+ expect(trackError).toHaveBeenCalledTimes(1);
45
+ expect(trackError).toHaveBeenCalledWith(expect.any(String));
46
+ },
47
+ );
48
+
49
+ it.each(['SHOULD', 'SHOULD_NOT'])(
50
+ 'does not track the error if the rule is %s',
51
+ ruleValue => {
52
+ const spy = jest.spyOn(console, 'warn');
53
+
54
+ jest
55
+ // @ts-ignore
56
+ .spyOn(Logger, 'getRuleAction')
57
+ // @ts-ignore
58
+ .mockReturnValue(ruleValue as RuleAction);
59
+
60
+ const { result } = renderHook(() => useChecks());
61
+
62
+ result.current?.logResult('test-me', {
63
+ rule: 'NO_UNDEFINED',
64
+ extra: 'extra',
65
+ message: 'message',
66
+ });
67
+
68
+ expect(trackError).not.toHaveBeenCalled();
69
+
70
+ expect(spy).toHaveBeenCalled();
71
+ },
72
+ );
73
+
74
+ it.each(['MUST_NOT', 'MUST'])(
75
+ 'tracks the same error only once',
76
+ ruleValue => {
77
+ jest
78
+ // @ts-ignore
79
+ .spyOn(Logger, 'getRuleAction')
80
+ // @ts-ignore
81
+ .mockReturnValue(ruleValue as RuleAction);
82
+
83
+ const { result } = renderHook(() => useChecks());
84
+
85
+ result.current?.logResult('test-me', {
86
+ rule: 'NO_UNDEFINED',
87
+ extra: 'extra',
88
+ message: 'message',
89
+ });
90
+
91
+ expect(trackError).toHaveBeenCalledTimes(1);
92
+
93
+ trackError.mockClear();
94
+
95
+ result.current?.logResult('test-me', {
96
+ rule: 'NO_UNDEFINED',
97
+ extra: 'extra',
98
+ message: 'message',
99
+ });
100
+
101
+ expect(trackError).toHaveBeenCalledTimes(0);
102
+ },
103
+ );
104
+
105
+ it('removes the tracked error after has been fixed', () => {
106
+ // @ts-ignore
107
+ jest.spyOn(Logger, 'getRuleAction').mockReturnValue('MUST');
108
+
109
+ const { result } = renderHook(() => useChecks());
110
+
111
+ result.current?.logResult('test-me', {
112
+ rule: 'NO_UNDEFINED',
113
+ extra: 'extra',
114
+ message: 'message',
115
+ });
116
+
117
+ expect(trackError).toHaveBeenCalledWith(expect.any(String));
118
+
119
+ result.current?.logResult('test-me', null);
120
+
121
+ expect(removeError).toHaveBeenCalledWith(trackError.mock.calls[0][0]);
122
+ });
123
+
124
+ it('checks for compatible accessibility state', () => {
125
+ const check = jest
126
+ .spyOn(CheckForAccessibilityState, 'checkForAccessibilityState')
127
+ .mockReturnValue(null);
128
+ const { result } = renderHook(() => useChecks());
129
+
130
+ result.current?.checkCompatibleAccessibilityState({
131
+ hello: 'world',
132
+ busy: true,
133
+ checked: 'wow',
134
+ accessibilityRole: 'button',
135
+ });
136
+
137
+ expect(check).toHaveBeenCalledWith({
138
+ accessibilityRole: 'button',
139
+ accessibilityState: undefined,
140
+ checked: 'wow',
141
+ });
142
+ });
143
+
144
+ it('checks for compatible accessibility state when accessibilityState is passed', () => {
145
+ const check = jest
146
+ .spyOn(CheckForAccessibilityState, 'checkForAccessibilityState')
147
+ .mockReturnValue(null);
148
+ const { result } = renderHook(() => useChecks());
149
+
150
+ result.current?.checkCompatibleAccessibilityState({
151
+ hello: 'world',
152
+ checked: false,
153
+ accessibilityState: {
154
+ selected: 'yeah',
155
+ },
156
+ accessibilityRole: 'button',
157
+ });
158
+
159
+ expect(check).toHaveBeenCalledWith({
160
+ accessibilityRole: 'button',
161
+ accessibilityState: {
162
+ selected: 'yeah',
163
+ },
164
+ checked: false,
165
+ });
166
+ });
167
+
168
+ it('checks for compatible accessibility role', () => {
169
+ const check = jest
170
+ .spyOn(CheckForAccessibilityRole, 'checkAccessibilityRole')
171
+ .mockReturnValue(null);
172
+ const { result } = renderHook(() => useChecks());
173
+
174
+ result.current?.checkAccessibilityRole('button');
175
+
176
+ expect(check).toHaveBeenCalledWith('button');
177
+ });
178
+ });
179
+
180
+ jest.mock('react-native', () => {
181
+ const ReactNative = jest.requireActual('react-native');
182
+
183
+ // Extend ReactNative
184
+ return Object.setPrototypeOf(
185
+ {
186
+ // Redefine an export, like a component
187
+ Button: 'Button',
188
+ // Mock out properties of an already mocked export
189
+ LayoutAnimation: {
190
+ ...ReactNative.LayoutAnimation,
191
+ configureNext: jest.fn(),
192
+ },
193
+ Platform: {
194
+ ...ReactNative.Platform,
195
+ OS: 'ios',
196
+ Version: 123,
197
+ isTesting: true,
198
+ select: (objs: { ios: any }) => objs.ios,
199
+ },
200
+ // Mock a native module
201
+ NativeModules: {
202
+ ...ReactNative.NativeModules,
203
+ Override: { great: 'success' },
204
+ },
205
+ InteractionManager: {
206
+ // @ts-ignore
207
+ runAfterInteractions: callback => {
208
+ callback();
209
+ },
210
+ },
211
+ },
212
+ ReactNative,
213
+ );
214
+ });
@@ -0,0 +1,207 @@
1
+ import {
2
+ ERROR_STYLE,
3
+ LogParams,
4
+ checkAccessibilityRole as checkAccessibilityRoleImplementation,
5
+ checkFocusTrap as checkFocusTrapImplementation,
6
+ checkForAccessibilityState,
7
+ checkMinimumSize as checkMinimumSizeImplementation,
8
+ contrastChecker as contrastCheckerImplementation,
9
+ getRuleAction,
10
+ logFailure,
11
+ noUndefinedProperty as noUndefinedPropertyImplementation,
12
+ uppercaseStringChecker as noUppercaseStringCheckerImplementation,
13
+ uppercaseChecker as uppercaseCheckerImplementation,
14
+ type CheckFocusTrap,
15
+ type CheckForAccessibilityState,
16
+ type ContrastChecker,
17
+ type NoUndefinedProperty,
18
+ type UppercaseChecker,
19
+ type UppercaseStringChecker,
20
+ } from '@react-native-ama/internal';
21
+ import * as React from 'react';
22
+ import {
23
+ AccessibilityRole,
24
+ InteractionManager,
25
+ LayoutChangeEvent,
26
+ } from 'react-native';
27
+
28
+ import { isDevContextValue, useAMAContext } from '../components/AMAProvider';
29
+
30
+ export const useChecks = () => {
31
+ const context = useAMAContext();
32
+
33
+ if (!isDevContextValue(context)) {
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Because the AMA Error view cannot be removed by default, this hacky solution
39
+ * allows the dev to hide it if all the issues have been solved.
40
+ */
41
+ const fakeRandom = React.useRef('' + Date.now() + Math.random() * 42);
42
+ const hasErrors = React.useRef(false);
43
+ const failedTests = React.useRef<string[]>([]);
44
+ const shouldCheckLayout = React.useRef(true);
45
+ const layoutCheckTimeout = React.useRef<NodeJS.Timeout>();
46
+ const [minimumSizeFailed, setMinimumSizeFailed] = React.useState(false);
47
+ const [debugStyle, setDebugStyle] = React.useState<any>({});
48
+
49
+ const { trackError, removeError } = context;
50
+
51
+ const logResult = (
52
+ name: string,
53
+ result: LogParams | LogParams[] | null,
54
+ ): Record<string, any> => {
55
+ const index = failedTests.current.indexOf(name);
56
+
57
+ if (result === null) {
58
+ if (index >= 0 && hasErrors.current) {
59
+ failedTests.current.splice(index);
60
+
61
+ hasErrors.current = false;
62
+
63
+ InteractionManager.runAfterInteractions(() => {
64
+ removeError(fakeRandom.current);
65
+
66
+ setTimeout(() => {
67
+ setDebugStyle({});
68
+ }, 100);
69
+ });
70
+ }
71
+ }
72
+
73
+ const results = Array.isArray(result) ? result : [result];
74
+
75
+ results.forEach(logParam => {
76
+ if (logParam === null) {
77
+ return;
78
+ }
79
+
80
+ const action = getRuleAction?.(logParam.rule);
81
+ const hasFailed = action === 'MUST_NOT' || action === 'MUST';
82
+
83
+ if (index < 0) {
84
+ // @ts-ignore
85
+ logFailure?.({ action, ...logParam });
86
+ }
87
+
88
+ if (!hasFailed) {
89
+ return;
90
+ } else if (index >= 0) {
91
+ return;
92
+ }
93
+
94
+ failedTests.current.push(name);
95
+
96
+ InteractionManager.runAfterInteractions(() => {
97
+ trackError(fakeRandom.current);
98
+
99
+ setDebugStyle(ERROR_STYLE);
100
+ });
101
+
102
+ hasErrors.current = true;
103
+ });
104
+
105
+ // @ts-ignore
106
+ return hasErrors.current ? ERROR_STYLE : {};
107
+ };
108
+
109
+ const noUndefinedProperty = <T>(params: NoUndefinedProperty<T>) =>
110
+ logResult(
111
+ `noUndefinedProperty ${params.property as string}`,
112
+ noUndefinedPropertyImplementation(params),
113
+ );
114
+
115
+ const contrastChecker = (params: ContrastChecker) =>
116
+ logResult('contrastChecker', contrastCheckerImplementation(params));
117
+
118
+ const checkMinimumSize = (params: LayoutChangeEvent) => {
119
+ return logResult(
120
+ 'checkMinimumSize',
121
+ checkMinimumSizeImplementation(params),
122
+ );
123
+ };
124
+
125
+ const noUppercaseStringChecker = (params: UppercaseStringChecker) =>
126
+ logResult(
127
+ 'accessibilityLabelChecker',
128
+ noUppercaseStringCheckerImplementation(params),
129
+ );
130
+
131
+ const uppercaseChecker = (params: UppercaseChecker) =>
132
+ logResult('uppercaseChecker', uppercaseCheckerImplementation(params));
133
+
134
+ const checkFocusTrap = (params: CheckFocusTrap) => {
135
+ checkFocusTrapImplementation(params).then(result => {
136
+ logResult('checkFocusTrap', result);
137
+ });
138
+ };
139
+
140
+ const checkAccessibilityRole = (param: AccessibilityRole) => {
141
+ logResult(
142
+ 'checkAccessibilityRole',
143
+ checkAccessibilityRoleImplementation(param),
144
+ );
145
+ };
146
+
147
+ const checkCompatibleAccessibilityState = (props: Record<string, any>) => {
148
+ const amaStates = ['checked', 'selected'];
149
+
150
+ const params: CheckForAccessibilityState = {
151
+ accessibilityRole: props.accessibilityRole,
152
+ accessibilityState: props?.accessibilityState,
153
+ };
154
+
155
+ amaStates.forEach(amaState => {
156
+ if (amaState in props) {
157
+ // @ts-ignore
158
+ params[amaState] = props[amaState];
159
+ }
160
+ });
161
+
162
+ logResult(
163
+ 'checkCompatibleAccessibilityState',
164
+ checkForAccessibilityState(params),
165
+ );
166
+ };
167
+
168
+ const onLayout = (event: LayoutChangeEvent) => {
169
+ /**
170
+ * When the check fails there are situation when adding a border makes the
171
+ * component meet the minimum size requirement, and this causes a loop as the
172
+ * state is update continuously between true and false.
173
+ * To "avoid" that we wait at least 100ms before checking the size again, as
174
+ * this gives the dev time to hot-reload the changes.
175
+ */
176
+ if (!shouldCheckLayout.current) {
177
+ return;
178
+ }
179
+
180
+ shouldCheckLayout.current = false;
181
+
182
+ // @ts-ignore
183
+ clearTimeout(layoutCheckTimeout.current);
184
+
185
+ layoutCheckTimeout.current = setTimeout(() => {
186
+ shouldCheckLayout.current = true;
187
+ }, 1000);
188
+
189
+ const result = checkMinimumSize(event);
190
+
191
+ setMinimumSizeFailed(Object.keys(result).length > 0);
192
+ };
193
+
194
+ return {
195
+ logResult,
196
+ noUndefinedProperty,
197
+ contrastChecker,
198
+ onLayout,
199
+ noUppercaseStringChecker,
200
+ uppercaseChecker,
201
+ checkFocusTrap,
202
+ minimumSizeFailed,
203
+ checkCompatibleAccessibilityState,
204
+ checkAccessibilityRole,
205
+ debugStyle,
206
+ };
207
+ };