@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-native-ama/core",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Accessible Mobile App Library for React Native",
5
5
  "private": false,
6
6
  "react-native": "src/index",
@@ -23,7 +23,7 @@
23
23
  "build": "rm -rf dist && tsc -p ./tsconfig.build.json",
24
24
  "typecheck": "tsc --noEmit"
25
25
  },
26
- "devDependencies": {
26
+ "dependencies": {
27
27
  "@react-native-ama/internal": "*"
28
28
  },
29
29
  "peerDependencies": {
@@ -0,0 +1,145 @@
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
+
5
+ import { AMAContextValue, AMAProvider, useAMAContext } from './AMAProvider';
6
+
7
+ beforeEach(() => {
8
+ jest.clearAllMocks();
9
+ });
10
+
11
+ describe('AMAProvider', () => {
12
+ describe('When __DEV__ is true', () => {
13
+ beforeEach(() => {
14
+ // @ts-ignore
15
+ global.__DEV__ = true;
16
+ });
17
+
18
+ describe('trackError', () => {
19
+ it('exports trackError', async () => {
20
+ await renderAMAProvider();
21
+
22
+ expect(amaContextValuesRef.trackError).toBeDefined();
23
+ });
24
+
25
+ it('shows the AMA error banner when called', async () => {
26
+ const renderAPI = await renderAMAProvider();
27
+
28
+ expect(renderAPI.queryByTestId('amaError')).toBeNull();
29
+
30
+ act(() => {
31
+ amaContextValuesRef.trackError('123');
32
+ });
33
+
34
+ expect(renderAPI.getByTestId('amaError')).toBeDefined();
35
+ expect(
36
+ renderAPI.getByTestId('amaError').props.accessibilityLabel,
37
+ ).toEqual("1 component(s) didn't pass the accessibility check(s)");
38
+
39
+ act(() => {
40
+ amaContextValuesRef.trackError('123');
41
+ });
42
+
43
+ expect(
44
+ renderAPI.getByTestId('amaError').props.accessibilityLabel,
45
+ ).toEqual("1 component(s) didn't pass the accessibility check(s)");
46
+
47
+ expect(renderAPI.getByTestId('amaError')).toBeDefined();
48
+
49
+ act(() => {
50
+ amaContextValuesRef.trackError('another');
51
+ });
52
+
53
+ expect(
54
+ renderAPI.getByTestId('amaError').props.accessibilityLabel,
55
+ ).toEqual("2 component(s) didn't pass the accessibility check(s)");
56
+
57
+ expect(renderAPI.getByTestId('amaError')).toBeDefined();
58
+ });
59
+ });
60
+
61
+ describe('removeError', () => {
62
+ it('exports removeError', async () => {
63
+ await renderAMAProvider();
64
+
65
+ expect(amaContextValuesRef.removeError).toBeDefined();
66
+ });
67
+
68
+ it('shows the AMA error banner when called', async () => {
69
+ const renderAPI = await renderAMAProvider();
70
+
71
+ expect(renderAPI.queryByTestId('amaError')).toBeNull();
72
+
73
+ act(() => {
74
+ amaContextValuesRef.trackError('123');
75
+ });
76
+
77
+ expect(
78
+ renderAPI.getByTestId('amaError').props.accessibilityLabel,
79
+ ).toEqual("1 component(s) didn't pass the accessibility check(s)");
80
+ expect(renderAPI.getByTestId('amaError')).toBeDefined();
81
+
82
+ act(() => {
83
+ amaContextValuesRef.removeError('invalidID');
84
+ });
85
+
86
+ expect(
87
+ renderAPI.getByTestId('amaError').props.accessibilityLabel,
88
+ ).toEqual("1 component(s) didn't pass the accessibility check(s)");
89
+
90
+ expect(renderAPI.getByTestId('amaError')).toBeDefined();
91
+
92
+ act(() => {
93
+ amaContextValuesRef.removeError('123');
94
+ });
95
+
96
+ expect(renderAPI.queryByTestId('amaError')).toBeNull();
97
+ });
98
+ });
99
+ });
100
+
101
+ describe('When __DEV__ is false', () => {
102
+ beforeEach(() => {
103
+ // @ts-ignore
104
+ global.__DEV__ = false;
105
+ });
106
+
107
+ it('does not export trackError', async () => {
108
+ await renderAMAProvider();
109
+
110
+ expect(amaContextValuesRef.trackError).toBeUndefined();
111
+ });
112
+
113
+ it('does not export removeError', async () => {
114
+ await renderAMAProvider();
115
+
116
+ expect(amaContextValuesRef.removeError).toBeUndefined();
117
+ });
118
+ });
119
+ });
120
+
121
+ const renderAMAProvider = async () => {
122
+ const result = render(
123
+ <AMAProvider>
124
+ <DummyComponent />
125
+ </AMAProvider>,
126
+ );
127
+
128
+ await act(async () => {
129
+ await flushMicroTasks();
130
+ });
131
+
132
+ return result;
133
+ };
134
+
135
+ let amaContextValuesRef: Extract<
136
+ AMAContextValue,
137
+ { trackError: Function; removeError: Function }
138
+ >;
139
+
140
+ const DummyComponent = () => {
141
+ // @ts-ignore
142
+ amaContextValuesRef = useAMAContext();
143
+
144
+ return null;
145
+ };
@@ -0,0 +1,199 @@
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 { AccessibilityInfo } from 'react-native';
5
+
6
+ import { AMAContextValue, AMAProvider } from './AMAProvider';
7
+
8
+ var mockProvider: jest.Mock;
9
+
10
+ beforeEach(() => {
11
+ jest.clearAllMocks();
12
+ });
13
+
14
+ describe('AMAProvider', () => {
15
+ it('adds event listener for "reduceMotionChanged"', async () => {
16
+ const spy = jest
17
+ .spyOn(AccessibilityInfo, 'addEventListener')
18
+ .mockReturnValue({ remove: jest.fn() } as any);
19
+
20
+ await renderAMAProvider();
21
+
22
+ expect(spy).toHaveBeenCalledWith(
23
+ 'reduceMotionChanged',
24
+ expect.any(Function),
25
+ );
26
+ });
27
+
28
+ it('adds event listener for "screenReaderChanged"', async () => {
29
+ const spy = jest
30
+ .spyOn(AccessibilityInfo, 'addEventListener')
31
+ .mockReturnValue({ remove: jest.fn() } as any);
32
+
33
+ await renderAMAProvider();
34
+
35
+ expect(spy).toHaveBeenCalledWith(
36
+ 'screenReaderChanged',
37
+ expect.any(Function),
38
+ );
39
+ });
40
+
41
+ it('setups the initial value for all the states', async () => {
42
+ const events = [
43
+ 'isReduceMotionEnabled',
44
+ 'isScreenReaderEnabled',
45
+ 'isReduceTransparencyEnabled',
46
+ 'isGrayscaleEnabled',
47
+ 'isBoldTextEnabled',
48
+ 'isInvertColorsEnabled',
49
+ ];
50
+
51
+ const states: any = {};
52
+
53
+ events.forEach(event => {
54
+ // @ts-ignore
55
+ jest.spyOn(AccessibilityInfo, event).mockResolvedValue('yay');
56
+ states[event] = 'yay';
57
+ });
58
+
59
+ await renderAMAProvider();
60
+
61
+ expect(mockProvider.mock.calls[1][0]).toMatchObject({
62
+ value: states,
63
+ });
64
+ });
65
+
66
+ it.each`
67
+ eventName | valueName
68
+ ${'reduceMotionChanged'} | ${'isReduceMotionEnabled'}
69
+ ${'screenReaderChanged'} | ${'isScreenReaderEnabled'}
70
+ ${'reduceTransparencyChanged'} | ${'isReduceTransparencyEnabled'}
71
+ ${'grayscaleChanged'} | ${'isGrayscaleEnabled'}
72
+ ${'boldTextChanged'} | ${'isBoldTextEnabled'}
73
+ ${'invertColorsChanged'} | ${'isInvertColorsEnabled'}
74
+ `(
75
+ 'reacts to the events change $eventName',
76
+ async ({
77
+ eventName,
78
+ valueName,
79
+ }: {
80
+ eventName: string;
81
+ valueName: keyof AMAContextValue;
82
+ }) => {
83
+ let handler: Function;
84
+ jest
85
+ .spyOn(AccessibilityInfo, 'addEventListener')
86
+ // @ts-ignore
87
+ .mockImplementation((name, callback) => {
88
+ if (name === eventName) {
89
+ handler = callback;
90
+ }
91
+
92
+ return {
93
+ remove: jest.fn(),
94
+ };
95
+ });
96
+
97
+ await renderAMAProvider();
98
+
99
+ const state: any = {};
100
+ state[valueName] = false;
101
+
102
+ expect(mockProvider).toHaveBeenCalledWith(
103
+ expect.objectContaining({
104
+ value: expect.objectContaining(state),
105
+ }),
106
+ {},
107
+ );
108
+
109
+ jest.clearAllMocks();
110
+
111
+ act(() => {
112
+ handler!(true);
113
+ });
114
+
115
+ state[valueName] = true;
116
+ expect(mockProvider).toHaveBeenCalledWith(
117
+ expect.objectContaining({
118
+ value: expect.objectContaining(state),
119
+ }),
120
+ {},
121
+ );
122
+
123
+ jest.clearAllMocks();
124
+
125
+ act(() => {
126
+ handler!(false);
127
+ });
128
+
129
+ state[valueName] = false;
130
+ expect(mockProvider).toHaveBeenCalledWith(
131
+ expect.objectContaining({
132
+ value: expect.objectContaining(state),
133
+ }),
134
+ {},
135
+ );
136
+ },
137
+ );
138
+
139
+ it.each`
140
+ eventName
141
+ ${'reduceMotionChanged'}
142
+ ${'screenReaderChanged'}
143
+ ${'reduceTransparencyChanged'}
144
+ ${'grayscaleChanged'}
145
+ ${'boldTextChanged'}
146
+ ${'invertColorsChanged'}
147
+ `(
148
+ 'remove the "$eventName" subscription when the provider is unmounted',
149
+ async ({ eventName }) => {
150
+ const removeMock = jest.fn();
151
+ jest
152
+ .spyOn(AccessibilityInfo, 'addEventListener')
153
+ // @ts-ignore
154
+ .mockImplementation((name, _callback) => {
155
+ return { remove: name === eventName ? removeMock : jest.fn() };
156
+ });
157
+
158
+ const renderAPI = await renderAMAProvider();
159
+ renderAPI.unmount();
160
+
161
+ expect(removeMock).toHaveBeenCalledWith();
162
+ },
163
+ );
164
+ });
165
+
166
+ const renderAMAProvider = async () => {
167
+ const result = render(
168
+ <AMAProvider>
169
+ <></>
170
+ </AMAProvider>,
171
+ );
172
+
173
+ await act(async () => {
174
+ await flushMicroTasks();
175
+ });
176
+
177
+ return result;
178
+ };
179
+
180
+ function mockCreateContext() {
181
+ const original = jest.requireActual('react');
182
+
183
+ mockProvider = jest.fn().mockReturnValue(null);
184
+
185
+ return {
186
+ ...original,
187
+ createContext: () => {
188
+ return {
189
+ Provider: mockProvider,
190
+ Consumer: () => {},
191
+ displayName: '',
192
+ };
193
+ },
194
+ };
195
+ }
196
+
197
+ jest.mock('react', () => {
198
+ return mockCreateContext();
199
+ });
@@ -0,0 +1,231 @@
1
+ /* eslint-disable react-native/no-inline-styles */
2
+
3
+ import { RED } from '@react-native-ama/internal';
4
+ import * as React from 'react';
5
+ import {
6
+ AccessibilityChangeEventName,
7
+ AccessibilityInfo,
8
+ NativeEventSubscription,
9
+ Text,
10
+ View,
11
+ } from 'react-native';
12
+
13
+ type AMAProviderProps = {
14
+ children: React.ReactNode;
15
+ };
16
+
17
+ type SharedContextValue = {
18
+ isBoldTextEnabled: boolean;
19
+ isScreenReaderEnabled: boolean;
20
+ isGrayscaleEnabled: boolean;
21
+ isInvertColorsEnabled: boolean;
22
+ isReduceMotionEnabled: boolean;
23
+ isReduceTransparencyEnabled: boolean;
24
+ reactNavigationScreenOptions: {
25
+ animationEnabled: boolean;
26
+ animation: 'default' | 'fade';
27
+ };
28
+ };
29
+ export type AMADevContextValue = SharedContextValue & {
30
+ trackError: (id: string) => void;
31
+ removeError: (id: string) => void;
32
+ };
33
+
34
+ export type AMAProdContextValue = SharedContextValue & {};
35
+
36
+ export type AMAContextValue = AMADevContextValue | AMAProdContextValue;
37
+
38
+ type AccessibilityEvents = Exclude<AccessibilityChangeEventName, 'change'>;
39
+
40
+ type AccessibilityInfoKey = Exclude<
41
+ keyof AMAContextValue,
42
+ 'reactNavigationScreenOptions' | 'trackError' | 'removeError'
43
+ >;
44
+
45
+ type Extractor<S extends AccessibilityEvents> = S extends `${infer R}Changed`
46
+ ? R
47
+ : never;
48
+
49
+ type AccessibilityInfoEvents = {
50
+ [key in AccessibilityEvents]: `is${Capitalize<Extractor<key>>}Enabled`;
51
+ };
52
+
53
+ const eventsMapping: AccessibilityInfoEvents = {
54
+ reduceTransparencyChanged: 'isReduceTransparencyEnabled',
55
+ reduceMotionChanged: 'isReduceMotionEnabled',
56
+ grayscaleChanged: 'isGrayscaleEnabled',
57
+ boldTextChanged: 'isBoldTextEnabled',
58
+ invertColorsChanged: 'isInvertColorsEnabled',
59
+ screenReaderChanged: 'isScreenReaderEnabled',
60
+ };
61
+
62
+ const isDev = __DEV__;
63
+
64
+ export const isDevContextValue = (
65
+ value: AMAContextValue,
66
+ ): value is AMADevContextValue =>
67
+ (value as AMADevContextValue).trackError !== undefined;
68
+
69
+ const DEFAULT_VALUES = {
70
+ isReduceTransparencyEnabled: false,
71
+ isBoldTextEnabled: false,
72
+ isGrayscaleEnabled: false,
73
+ isInvertColorsEnabled: false,
74
+ isReduceMotionEnabled: false,
75
+ isScreenReaderEnabled: false,
76
+ reactNavigationScreenOptions: {
77
+ animationEnabled: true,
78
+ animation: 'default',
79
+ },
80
+ } satisfies AMAContextValue;
81
+
82
+ const AMAContext = React.createContext<AMAContextValue | null>(null);
83
+
84
+ export const AMAProvider: React.FC<AMAProviderProps> = ({ children }) => {
85
+ const [values, setValues] = React.useState<AMAContextValue>(DEFAULT_VALUES);
86
+
87
+ const handleAccessibilityInfoChanged = (key: AccessibilityInfoKey) => {
88
+ return (newValue: boolean) => {
89
+ setValues(oldValues => {
90
+ const newValues = { ...oldValues };
91
+ newValues[key] = newValue;
92
+
93
+ if (key === 'isReduceMotionEnabled') {
94
+ newValues.reactNavigationScreenOptions.animationEnabled = !newValue;
95
+ newValues.reactNavigationScreenOptions.animation = newValue
96
+ ? 'fade'
97
+ : 'default';
98
+ }
99
+
100
+ return {
101
+ ...oldValues,
102
+ ...newValues,
103
+ };
104
+ });
105
+ };
106
+ };
107
+
108
+ React.useEffect(() => {
109
+ const allInitPromises: Promise<boolean>[] = [];
110
+
111
+ const subscriptions: NativeEventSubscription[] = Object.entries(
112
+ eventsMapping,
113
+ ).map(([eventName, contextKey]) => {
114
+ allInitPromises.push(AccessibilityInfo[contextKey]());
115
+
116
+ return AccessibilityInfo.addEventListener(
117
+ eventName as AccessibilityEvents,
118
+ handleAccessibilityInfoChanged(contextKey),
119
+ );
120
+ });
121
+
122
+ Promise.all(allInitPromises).then(promisesValues => {
123
+ const newValues = Object.values(eventsMapping).reduce(
124
+ (list, key, index) => {
125
+ list[key] = promisesValues[index] as boolean;
126
+
127
+ return list;
128
+ },
129
+ {} as AMAContextValue,
130
+ );
131
+
132
+ setValues(oldValues => {
133
+ return {
134
+ ...oldValues,
135
+ ...newValues,
136
+ };
137
+ });
138
+ });
139
+
140
+ return () => {
141
+ subscriptions.forEach(subscription => subscription?.remove());
142
+ };
143
+ }, []);
144
+
145
+ const [failedItems, setFailedItems] = React.useState<string[]>([]);
146
+
147
+ if (isDev) {
148
+ const trackError = (id: string) => {
149
+ if (failedItems.includes(id)) {
150
+ return;
151
+ }
152
+
153
+ setFailedItems(items => [...items, id]);
154
+
155
+ AccessibilityInfo.announceForAccessibility(
156
+ "One or more component didn't pass the accessibility check, please check the console for more info",
157
+ );
158
+ };
159
+
160
+ const removeError = (id: string) => {
161
+ setFailedItems(items => {
162
+ const index = items.indexOf(id);
163
+
164
+ if (index >= 0) {
165
+ items.splice(index);
166
+
167
+ return [...items];
168
+ }
169
+
170
+ return items;
171
+ });
172
+ };
173
+
174
+ return (
175
+ <AMAContext.Provider
176
+ value={{
177
+ ...values,
178
+ trackError,
179
+ removeError,
180
+ }}>
181
+ <View style={{ flex: 1 }}>
182
+ <>
183
+ {children}
184
+ {failedItems.length > 0 ? (
185
+ <AMAError count={failedItems.length} />
186
+ ) : null}
187
+ </>
188
+ </View>
189
+ </AMAContext.Provider>
190
+ );
191
+ }
192
+
193
+ return <AMAContext.Provider value={values}>{children}</AMAContext.Provider>;
194
+ };
195
+
196
+ const AMAError = ({ count }: { count: number }) => {
197
+ const error = `${count} component(s) didn't pass the accessibility check(s)`;
198
+
199
+ return (
200
+ <View
201
+ accessibilityLabel={error}
202
+ accessibilityHint="Please check the console for more info..."
203
+ style={{
204
+ paddingHorizontal: 24,
205
+ paddingTop: 24,
206
+ paddingBottom: 48,
207
+ backgroundColor: RED,
208
+ }}
209
+ testID="amaError">
210
+ <View accessible={true}>
211
+ <Text
212
+ style={{ color: 'white', fontSize: 16, lineHeight: 26 }}
213
+ testID="amaError.message">
214
+ {error}
215
+ </Text>
216
+ <Text style={{ color: 'white', fontSize: 16, lineHeight: 24 }}>
217
+ Please check the console for more info...
218
+ </Text>
219
+ </View>
220
+ </View>
221
+ );
222
+ };
223
+
224
+ export const useAMAContext = () => {
225
+ const context = React.useContext(AMAContext);
226
+ if (!context) {
227
+ throw new Error('Please wrap your app with <AMAProvider />');
228
+ }
229
+
230
+ return context;
231
+ };
@@ -0,0 +1,69 @@
1
+ import { render, waitFor } from '@testing-library/react-native';
2
+ import * as React from 'react';
3
+
4
+ import * as UseFocus from '../hooks/useFocus';
5
+ import { AutofocusContainer } from './AutofocusContainer';
6
+
7
+ beforeEach(() => {
8
+ jest.clearAllMocks();
9
+ });
10
+
11
+ describe('AutofocusContainer', () => {
12
+ it('it call setFocus when gets rendered', async () => {
13
+ const setFocus = jest.fn();
14
+ jest.spyOn(UseFocus, 'useFocus').mockReturnValue({
15
+ setFocus,
16
+ });
17
+
18
+ render(
19
+ <AutofocusContainer>
20
+ <></>
21
+ </AutofocusContainer>,
22
+ );
23
+
24
+ await waitFor(() => expect(setFocus).toBeCalled());
25
+ });
26
+
27
+ it.each([undefined, true])(
28
+ 'wraps the children with an accessible View when wrapChildrenInAccessibleView is %s',
29
+ async () => {
30
+ const setFocus = jest.fn();
31
+ jest.spyOn(UseFocus, 'useFocus').mockReturnValue({
32
+ setFocus,
33
+ });
34
+
35
+ const renderAPI = render(
36
+ <AutofocusContainer wrapChildrenInAccessibleView={true}>
37
+ <></>
38
+ </AutofocusContainer>,
39
+ );
40
+
41
+ await waitFor(() => expect(setFocus).toBeCalled());
42
+
43
+ expect(
44
+ renderAPI.getByTestId('autofocusContainer.accessibleView'),
45
+ ).toBeDefined();
46
+ },
47
+ );
48
+
49
+ it('does not wrap the children with an accessible View when wrapChildrenInAccessibleView is false', async () => {
50
+ const setFocus = jest.fn();
51
+ jest.spyOn(UseFocus, 'useFocus').mockReturnValue({
52
+ setFocus,
53
+ });
54
+
55
+ const renderAPI = render(
56
+ <AutofocusContainer wrapChildrenInAccessibleView={false}>
57
+ <></>
58
+ </AutofocusContainer>,
59
+ );
60
+
61
+ await waitFor(() => expect(setFocus).toBeCalled());
62
+
63
+ expect(
64
+ renderAPI.queryByTestId('autofocusContainer.accessibleView'),
65
+ ).toBeNull();
66
+ });
67
+ });
68
+
69
+ jest.mock('../hooks/useFocus');
@@ -0,0 +1,43 @@
1
+ import * as React from 'react';
2
+ import { TouchableWithoutFeedback, View, ViewProps } from 'react-native';
3
+
4
+ import { useFocus } from '../hooks/useFocus';
5
+
6
+ type AutofocusContainerProps = React.PropsWithChildren<
7
+ | ({
8
+ wrapChildrenInAccessibleView?: true;
9
+ } & ViewProps)
10
+ | {
11
+ wrapChildrenInAccessibleView: false;
12
+ }
13
+ >;
14
+
15
+ export const AutofocusContainer = ({
16
+ children,
17
+ wrapChildrenInAccessibleView = true,
18
+ ...viewProps
19
+ }: AutofocusContainerProps) => {
20
+ const containerRef = React.useRef(null);
21
+ const { setFocus } = useFocus();
22
+
23
+ React.useEffect(() => {
24
+ setTimeout(() => {
25
+ setFocus(containerRef.current);
26
+ }, 0);
27
+ }, [setFocus]);
28
+
29
+ return wrapChildrenInAccessibleView ? (
30
+ <TouchableWithoutFeedback ref={containerRef}>
31
+ <View
32
+ accessible={true}
33
+ testID="autofocusContainer.accessibleView"
34
+ {...viewProps}>
35
+ {children}
36
+ </View>
37
+ </TouchableWithoutFeedback>
38
+ ) : (
39
+ <TouchableWithoutFeedback ref={containerRef}>
40
+ {children}
41
+ </TouchableWithoutFeedback>
42
+ );
43
+ };