@react-native-ama/core 1.2.1 → 2.0.0-beta.0
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/dist/ReactNativeAma.types.d.ts +66 -0
- package/dist/ReactNativeAma.types.js +2 -0
- package/dist/ReactNativeAmaModule.d.ts +11 -0
- package/dist/ReactNativeAmaModule.js +5 -0
- package/dist/ReactNativeAmaModule.web.d.ts +9 -0
- package/dist/ReactNativeAmaModule.web.js +27 -0
- package/dist/ReactNativeAmaView.d.ts +3 -0
- package/dist/ReactNativeAmaView.js +32 -0
- package/dist/ReactNativeAmaView.web.d.ts +3 -0
- package/dist/ReactNativeAmaView.web.js +34 -0
- package/dist/{src/components → components}/AMAProvider.d.ts +4 -2
- package/dist/{src/components → components}/AMAProvider.js +19 -45
- package/dist/{src/components → components}/AutofocusContainer.d.ts +2 -1
- package/dist/{src/components → components}/HideChildrenFromAccessibilityTree.d.ts +1 -1
- package/dist/{src/components → components}/HideChildrenFromAccessibilityTree.js +7 -5
- package/dist/{src/hooks → hooks}/useFocus.js +10 -3
- package/dist/{src/index.d.ts → index.d.ts} +3 -2
- package/dist/index.js +38 -0
- package/dist/internals/checks/checkAriaLabel.d.ts +3 -0
- package/dist/internals/checks/checkAriaLabel.js +15 -0
- package/dist/internals/checks/checkAriaRole.d.ts +3 -0
- package/dist/internals/checks/checkAriaRole.js +48 -0
- package/dist/internals/checks/checkContrast.d.ts +3 -0
- package/dist/internals/checks/checkContrast.js +84 -0
- package/dist/internals/checks/checkImageAlt.d.ts +3 -0
- package/dist/internals/checks/checkImageAlt.js +20 -0
- package/dist/internals/checks/checkIsUppercase.d.ts +8 -0
- package/dist/internals/checks/checkIsUppercase.js +30 -0
- package/dist/internals/checks/checkLongNumber.d.ts +3 -0
- package/dist/internals/checks/checkLongNumber.js +31 -0
- package/dist/internals/checks/checkMinimumSize.d.ts +3 -0
- package/dist/internals/checks/checkMinimumSize.js +22 -0
- package/dist/internals/checks/checkTextInput.d.ts +3 -0
- package/dist/internals/checks/checkTextInput.js +51 -0
- package/dist/internals/checks/performChecks.d.ts +3 -0
- package/dist/internals/checks/performChecks.js +23 -0
- package/dist/internals/components/AMAErrorOverlay.d.ts +5 -0
- package/dist/internals/components/AMAErrorOverlay.js +334 -0
- package/dist/internals/components/AMARuleError.d.ts +7 -0
- package/dist/internals/components/AMARuleError.js +161 -0
- package/dist/internals/config.d.ts +23 -0
- package/dist/internals/config.js +33 -0
- package/dist/internals/types.d.ts +9 -0
- package/dist/internals/types.js +2 -0
- package/dist/internals/useAMADev.d.ts +8 -0
- package/dist/internals/useAMADev.dev.d.ts +2 -0
- package/dist/internals/useAMADev.dev.js +110 -0
- package/dist/internals/useAMADev.js +6 -0
- package/dist/internals/useAMADevNodeChecks.dev.d.ts +16 -0
- package/dist/internals/useAMADevNodeChecks.dev.js +115 -0
- package/dist/internals/useAMADevUiInteraction.dev.d.ts +7 -0
- package/dist/internals/useAMADevUiInteraction.dev.js +100 -0
- package/dist/internals/utils/amaClearHighlight.d.ts +2 -0
- package/dist/internals/utils/amaClearHighlight.js +12 -0
- package/dist/internals/utils/amaHighlightComponent.d.ts +3 -0
- package/dist/internals/utils/amaHighlightComponent.js +51 -0
- package/dist/internals/utils/constants.d.ts +3 -0
- package/dist/internals/utils/constants.js +32 -0
- package/dist/internals/utils/getErrorColor.d.ts +2 -0
- package/dist/internals/utils/getErrorColor.js +10 -0
- package/dist/internals/utils/getRuleAction.d.ts +2 -0
- package/dist/internals/utils/getRuleAction.js +40 -0
- package/dist/internals/utils/getRuleErrorInfo.d.ts +7 -0
- package/dist/internals/utils/getRuleErrorInfo.js +19 -0
- package/dist/internals/utils/ignoreContrastCheck.d.ts +1 -0
- package/dist/internals/utils/ignoreContrastCheck.js +15 -0
- package/dist/internals/utils/interpolateAnimationStates.d.ts +2 -0
- package/dist/internals/utils/interpolateAnimationStates.js +43 -0
- package/dist/internals/utils/isA11yLabelAllowed.d.ts +1 -0
- package/dist/internals/utils/isA11yLabelAllowed.js +14 -0
- package/dist/internals/utils/isRuleDisabled.d.ts +2 -0
- package/dist/internals/utils/isRuleDisabled.js +10 -0
- package/dist/internals/utils/logError.d.ts +2 -0
- package/dist/internals/utils/logError.js +36 -0
- package/dist/internals/utils/logFoundIssues.d.ts +2 -0
- package/dist/internals/utils/logFoundIssues.js +23 -0
- package/dist/internals/utils/logger.d.ts +6 -0
- package/dist/internals/utils/logger.js +15 -0
- package/dist/internals/utils/minimumTouchableSize.d.ts +1 -0
- package/dist/internals/utils/minimumTouchableSize.js +9 -0
- package/dist/internals/utils/platformHelpers.d.ts +2 -0
- package/dist/internals/utils/platformHelpers.js +6 -0
- package/dist/internals/utils/rules.d.ts +22 -0
- package/dist/internals/utils/rules.js +253 -0
- package/package.json +61 -18
- package/src/ReactNativeAma.types.ts +70 -0
- package/src/ReactNativeAmaModule.ts +18 -0
- package/src/ReactNativeAmaModule.web.ts +14 -0
- package/src/ReactNativeAmaView.tsx +9 -0
- package/src/ReactNativeAmaView.web.tsx +15 -0
- package/src/__mocks__/ReactNativeAmaModule.ts +9 -0
- package/src/components/AMAProvider.integration.test.tsx +3 -96
- package/src/components/AMAProvider.test.tsx +9 -4
- package/src/components/AMAProvider.tsx +40 -76
- package/src/components/AutofocusContainer.test.tsx +4 -5
- package/src/components/AutofocusContainer.tsx +20 -5
- package/src/components/HideChildrenFromAccessibilityTree.test.tsx +23 -39
- package/src/components/HideChildrenFromAccessibilityTree.tsx +12 -11
- package/src/hooks/useFocus.test.ts +20 -20
- package/src/hooks/useFocus.ts +14 -6
- package/src/hooks/useTimedAction.android.test.ts +0 -1
- package/src/hooks/useTimedAction.ios.test.ts +0 -1
- package/src/hooks/useTimedAction.ts +0 -1
- package/src/index.ts +4 -2
- package/src/internals/checks/checkAriaLabel.test.ts +165 -0
- package/src/internals/checks/checkAriaLabel.ts +16 -0
- package/src/internals/checks/checkAriaRole.test.ts +429 -0
- package/src/internals/checks/checkAriaRole.ts +60 -0
- package/src/internals/checks/checkContrast.test.ts +278 -0
- package/src/internals/checks/checkContrast.ts +109 -0
- package/src/internals/checks/checkImageAlt.test.ts +125 -0
- package/src/internals/checks/checkImageAlt.ts +23 -0
- package/src/internals/checks/checkIsUppercase.test.ts +399 -0
- package/src/internals/checks/checkIsUppercase.ts +46 -0
- package/src/internals/checks/checkLongNumber.test.ts +111 -0
- package/src/internals/checks/checkLongNumber.ts +34 -0
- package/src/internals/checks/checkMinimumSize.test.ts +67 -0
- package/src/internals/checks/checkMinimumSize.ts +25 -0
- package/src/internals/checks/checkTextInput.ts +65 -0
- package/src/internals/checks/performChecks.test.ts +171 -0
- package/src/internals/checks/performChecks.ts +24 -0
- package/src/internals/components/AMAErrorOverlay.test.tsx +139 -0
- package/src/internals/components/AMAErrorOverlay.tsx +451 -0
- package/src/internals/components/AMARuleError.test.tsx +93 -0
- package/src/internals/components/AMARuleError.tsx +225 -0
- package/src/internals/config.ts +58 -0
- package/src/internals/types.ts +11 -0
- package/src/internals/useAMADev.dev.ts +153 -0
- package/src/internals/useAMADev.test.ts +260 -0
- package/src/internals/useAMADev.ts +17 -0
- package/src/internals/useAMADevNodeChecks.dev.ts +178 -0
- package/src/internals/useAMADevUiInteraction.dev.ts +164 -0
- package/src/internals/utils/amaClearHighlight.test.ts +21 -0
- package/src/internals/utils/amaClearHighlight.ts +8 -0
- package/src/internals/utils/amaHighlightComponent.test.ts +74 -0
- package/src/internals/utils/amaHighlightComponent.ts +46 -0
- package/src/internals/utils/constants.ts +33 -0
- package/src/internals/utils/devMode.test.ts +99 -0
- package/src/internals/utils/getErrorColor.test.ts +18 -0
- package/src/internals/utils/getErrorColor.ts +8 -0
- package/src/internals/utils/getRuleAction.ts +13 -0
- package/src/internals/utils/getRuleErrorInfo.test.ts +59 -0
- package/src/internals/utils/getRuleErrorInfo.ts +26 -0
- package/src/internals/utils/ignoreContrastCheck.test.ts +34 -0
- package/src/internals/utils/ignoreContrastCheck.ts +11 -0
- package/src/internals/utils/interpolateAnimation.test.ts +176 -0
- package/src/internals/utils/interpolateAnimationStates.ts +55 -0
- package/src/internals/utils/isA11yLabelAllowed.test.ts +39 -0
- package/src/internals/utils/isA11yLabelAllowed.ts +10 -0
- package/src/internals/utils/isRuleDisabled.test.ts +35 -0
- package/src/internals/utils/isRuleDisabled.ts +10 -0
- package/src/internals/utils/logError.test.ts +148 -0
- package/src/internals/utils/logError.ts +41 -0
- package/src/internals/utils/logFoundIssues.test.ts +98 -0
- package/src/internals/utils/logFoundIssues.ts +22 -0
- package/src/internals/utils/logger.test.ts +26 -0
- package/src/internals/utils/logger.ts +15 -0
- package/src/internals/utils/loggerMethods.test.ts +38 -0
- package/src/internals/utils/minimumTouchableSize.test.ts +28 -0
- package/src/internals/utils/minimumTouchableSize.tsx +9 -0
- package/src/internals/utils/platformHelpers.test.ts +11 -0
- package/src/internals/utils/platformHelpers.ts +4 -0
- package/src/internals/utils/rules.ts +363 -0
- package/dist/scripts/create-config.d.ts +0 -1
- package/dist/scripts/create-config.js +0 -23
- package/dist/src/hooks/useButtonChecks.d.ts +0 -6
- package/dist/src/hooks/useButtonChecks.js +0 -51
- package/dist/src/hooks/useChecks.d.ts +0 -15
- package/dist/src/hooks/useChecks.js +0 -152
- package/dist/src/index.js +0 -20
- package/src/hooks/useButtonChecks.ts +0 -73
- package/src/hooks/useChecks.test.ts +0 -214
- package/src/hooks/useChecks.ts +0 -207
- /package/dist/{src/components → components}/AutofocusContainer.js +0 -0
- /package/dist/{src/hooks → hooks}/useFocus.d.ts +0 -0
- /package/dist/{src/hooks → hooks}/useTimedAction.d.ts +0 -0
- /package/dist/{src/hooks → hooks}/useTimedAction.js +0 -0
- /package/dist/{src/utils → utils}/numerify.d.ts +0 -0
- /package/dist/{src/utils → utils}/numerify.js +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { AmaNode } from '../../ReactNativeAma.types';
|
|
2
|
+
import { checkContrast } from './checkContrast';
|
|
3
|
+
|
|
4
|
+
jest.mock('../utils/ignoreContrastCheck', () => ({
|
|
5
|
+
shouldIgnoreContrastCheckForDisabledElement: jest.fn(() => false),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
jest.mock('../utils/getRuleAction', () => ({
|
|
9
|
+
getRuleAction: jest.fn(() => 'SHOULD'),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks();
|
|
14
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
15
|
+
getRuleAction.mockReturnValue('SHOULD');
|
|
16
|
+
|
|
17
|
+
const { shouldIgnoreContrastCheckForDisabledElement } = require('../utils/ignoreContrastCheck');
|
|
18
|
+
shouldIgnoreContrastCheckForDisabledElement.mockReturnValue(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Contrast ratios (verified against WCAG algorithm):
|
|
22
|
+
// #000000 on #FFFFFF = 21.00:1
|
|
23
|
+
// #595959 on #FFFFFF = 7.00:1 (passes AAA normal: >=7.0)
|
|
24
|
+
// #606060 on #FFFFFF = 6.29:1 (fails AAA normal, passes AA)
|
|
25
|
+
// #757575 on #FFFFFF = 4.61:1 (fails AAA normal, passes AA)
|
|
26
|
+
// #777777 on #FFFFFF = 4.48:1 (fails AA normal)
|
|
27
|
+
// #909090 on #FFFFFF = 3.19:1 (fails AAA large: <4.5, passes AA large: >=3.0)
|
|
28
|
+
// #949494 on #FFFFFF = 3.03:1 (fails AAA large, passes AA large)
|
|
29
|
+
// #959595 on #FFFFFF = 2.99:1 (fails AA large: <3.0)
|
|
30
|
+
|
|
31
|
+
describe('checkContrast', () => {
|
|
32
|
+
const createNode = (overrides: Partial<AmaNode> = {}): AmaNode => ({
|
|
33
|
+
type: 'Text',
|
|
34
|
+
viewId: 1,
|
|
35
|
+
bounds: [0, 0, 100, 100],
|
|
36
|
+
content: 'Test content',
|
|
37
|
+
fg: '#000000',
|
|
38
|
+
bg: '#FFFFFF',
|
|
39
|
+
fontSize: 16,
|
|
40
|
+
isBold: false,
|
|
41
|
+
isEnabled: true,
|
|
42
|
+
isAccessible: true,
|
|
43
|
+
hasOnSubmitEditing: false,
|
|
44
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('Color conversion and contrast calculation', () => {
|
|
48
|
+
it('passes with high contrast black on white (21:1 ratio)', () => {
|
|
49
|
+
expect(checkContrast(createNode({ fg: '#000000', bg: '#FFFFFF' }))).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('passes with white on black', () => {
|
|
53
|
+
expect(checkContrast(createNode({ fg: '#FFFFFF', bg: '#000000' }))).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles shorthand hex colors (#FFF)', () => {
|
|
57
|
+
expect(checkContrast(createNode({ fg: '#000', bg: '#FFF' }))).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles hex colors without # prefix', () => {
|
|
61
|
+
expect(checkContrast(createNode({ fg: '000000', bg: 'FFFFFF' }))).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('WCAG AA compliance (normal text)', () => {
|
|
66
|
+
it('fails AA with contrast ratio below 4.5:1 for normal text', () => {
|
|
67
|
+
// #777777 on white = 4.48:1 — fails AA
|
|
68
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
69
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
70
|
+
|
|
71
|
+
const node = createNode({ fg: '#777777', bg: '#FFFFFF', fontSize: 16 });
|
|
72
|
+
const result = checkContrast(node);
|
|
73
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED', viewId: 1 });
|
|
74
|
+
expect(result?.extra).toContain('4.5');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('passes AA with contrast ratio >=4.5:1 for normal text (reports AAA failure)', () => {
|
|
78
|
+
// #757575 on white = 4.61:1 — passes AA, fails AAA (< 7.0)
|
|
79
|
+
const node = createNode({ fg: '#757575', bg: '#FFFFFF', fontSize: 16 });
|
|
80
|
+
const result = checkContrast(node);
|
|
81
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED_AAA' });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('WCAG AAA compliance', () => {
|
|
86
|
+
it('fails AAA with contrast ratio below 7:1 for normal text', () => {
|
|
87
|
+
// #606060 on white = 6.29:1 — passes AA, fails AAA
|
|
88
|
+
const node = createNode({ fg: '#606060', bg: '#FFFFFF', fontSize: 16 });
|
|
89
|
+
const result = checkContrast(node);
|
|
90
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED_AAA', viewId: 1 });
|
|
91
|
+
expect(result?.extra).toContain('7');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('passes AAA with 7:1 contrast ratio for normal text', () => {
|
|
95
|
+
// #595959 on white = exactly 7.00:1 — passes AAA
|
|
96
|
+
const node = createNode({ fg: '#595959', bg: '#FFFFFF', fontSize: 16 });
|
|
97
|
+
expect(checkContrast(node)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('Large text rules', () => {
|
|
102
|
+
it('treats 18pt text as large — AAA requires 4.5:1 for large text', () => {
|
|
103
|
+
// #909090 on white = 3.19:1 — passes large AA (>=3.0), fails large AAA (< 4.5)
|
|
104
|
+
const node = createNode({ fg: '#909090', bg: '#FFFFFF', fontSize: 18 });
|
|
105
|
+
const result = checkContrast(node);
|
|
106
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED_AAA' });
|
|
107
|
+
expect(result?.extra).toContain('4.5');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('treats 14pt bold text as large', () => {
|
|
111
|
+
// #949494 on white = 3.03:1 — passes large AA (>=3.0), fails large AAA (< 4.5)
|
|
112
|
+
const node = createNode({ fg: '#949494', bg: '#FFFFFF', fontSize: 14, isBold: true });
|
|
113
|
+
const result = checkContrast(node);
|
|
114
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED_AAA' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not treat 14pt non-bold text as large (uses normal text thresholds)', () => {
|
|
118
|
+
// #949494 = 3.03:1 — fails normal AA (<4.5), and with AAA disabled, returns CONTRAST_FAILED
|
|
119
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
120
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
121
|
+
|
|
122
|
+
const node = createNode({ fg: '#949494', bg: '#FFFFFF', fontSize: 14, isBold: false });
|
|
123
|
+
const result = checkContrast(node);
|
|
124
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED' });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('fails AA for large text with contrast below 3:1', () => {
|
|
128
|
+
// #959595 on white = 2.99:1 — fails large AA (<3.0)
|
|
129
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
130
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
131
|
+
|
|
132
|
+
const node = createNode({ fg: '#959595', bg: '#FFFFFF', fontSize: 18 });
|
|
133
|
+
const result = checkContrast(node);
|
|
134
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED' });
|
|
135
|
+
expect(result?.extra).toContain('3.0');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('Edge cases and validation', () => {
|
|
140
|
+
it('returns null when foreground color is missing', () => {
|
|
141
|
+
expect(checkContrast(createNode({ fg: undefined }))).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns null when background color is missing', () => {
|
|
145
|
+
expect(checkContrast(createNode({ bg: undefined }))).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns null when content is missing (icon-only buttons)', () => {
|
|
149
|
+
expect(checkContrast(createNode({ content: undefined }))).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns null when content is empty string', () => {
|
|
153
|
+
expect(checkContrast(createNode({ content: '' }))).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('handles unknown/invalid hex color for foreground gracefully', () => {
|
|
157
|
+
// Invalid hex → luminance 0 → very high contrast with white → passes or fails depending on bg
|
|
158
|
+
// 'invalid' gives luminance 0, white gives 1.0, contrast = 21:1 → passes
|
|
159
|
+
// This verifies we don't throw on invalid input
|
|
160
|
+
expect(() => checkContrast(createNode({ fg: 'invalid', bg: '#FFFFFF' }))).not.toThrow();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('handles unknown/invalid hex color for background gracefully', () => {
|
|
164
|
+
expect(() => checkContrast(createNode({ fg: '#000000', bg: 'invalid' }))).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('Disabled element handling', () => {
|
|
169
|
+
it('checks contrast for disabled elements when ignore setting is false', () => {
|
|
170
|
+
const { shouldIgnoreContrastCheckForDisabledElement } = require('../utils/ignoreContrastCheck');
|
|
171
|
+
shouldIgnoreContrastCheckForDisabledElement.mockReturnValue(false);
|
|
172
|
+
|
|
173
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
174
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
175
|
+
|
|
176
|
+
// #777777 = 4.48:1 — fails AA for normal text
|
|
177
|
+
const node = createNode({ isEnabled: false, fg: '#777777', bg: '#FFFFFF', fontSize: 16 });
|
|
178
|
+
expect(checkContrast(node)).not.toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('skips contrast check for disabled elements when ignore setting is true', () => {
|
|
182
|
+
const { shouldIgnoreContrastCheckForDisabledElement } = require('../utils/ignoreContrastCheck');
|
|
183
|
+
shouldIgnoreContrastCheckForDisabledElement.mockReturnValue(true);
|
|
184
|
+
|
|
185
|
+
const node = createNode({ isEnabled: false, fg: '#777777', bg: '#FFFFFF', fontSize: 16 });
|
|
186
|
+
expect(checkContrast(node)).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('Error message formatting', () => {
|
|
191
|
+
it('includes foreground and background colors in error message', () => {
|
|
192
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
193
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
194
|
+
|
|
195
|
+
// #777777 = 4.48:1 — fails AA
|
|
196
|
+
const node = createNode({ fg: '#777777', bg: '#FFFFFF', fontSize: 16 });
|
|
197
|
+
const result = checkContrast(node);
|
|
198
|
+
expect(result?.extra).toContain('#777777');
|
|
199
|
+
expect(result?.extra).toContain('#FFFFFF');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('includes calculated contrast ratio in error message', () => {
|
|
203
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
204
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
205
|
+
|
|
206
|
+
const node = createNode({ fg: '#777777', bg: '#FFFFFF', fontSize: 16 });
|
|
207
|
+
const result = checkContrast(node);
|
|
208
|
+
expect(result?.extra).toMatch(/\d+\.\d{2}/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('includes required contrast ratio in error message', () => {
|
|
212
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
213
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
214
|
+
|
|
215
|
+
const node = createNode({ fg: '#777777', bg: '#FFFFFF', fontSize: 16 });
|
|
216
|
+
const result = checkContrast(node);
|
|
217
|
+
expect(result?.extra).toContain('4.5');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('includes ariaLabel in error when present', () => {
|
|
221
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
222
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
223
|
+
|
|
224
|
+
const node = createNode({ fg: '#777777', bg: '#FFFFFF', fontSize: 16, ariaLabel: 'Test button' });
|
|
225
|
+
const result = checkContrast(node);
|
|
226
|
+
expect(result?.label).toBe('Test button');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('includes viewId in error', () => {
|
|
230
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
231
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
232
|
+
|
|
233
|
+
const node = createNode({ fg: '#777777', bg: '#FFFFFF', fontSize: 16, viewId: 12345 });
|
|
234
|
+
const result = checkContrast(node);
|
|
235
|
+
expect(result?.viewId).toBe(12345);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('AAA config-driven behavior', () => {
|
|
240
|
+
it('reports AAA failure when AAA check is enabled', () => {
|
|
241
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
242
|
+
getRuleAction.mockReturnValue('SHOULD');
|
|
243
|
+
|
|
244
|
+
// #606060 on white = 6.29:1 — passes AA, fails AAA
|
|
245
|
+
const node = createNode({ fg: '#606060', bg: '#FFFFFF', fontSize: 16 });
|
|
246
|
+
const result = checkContrast(node);
|
|
247
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED_AAA' });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('does not report AAA failure when AAA check is disabled', () => {
|
|
251
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
252
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
253
|
+
|
|
254
|
+
// #606060 on white = 6.29:1 — passes AA, so no error when AAA disabled
|
|
255
|
+
const node = createNode({ fg: '#606060', bg: '#FFFFFF', fontSize: 16 });
|
|
256
|
+
expect(checkContrast(node)).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('reports AA failure regardless of AAA config', () => {
|
|
260
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
261
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
262
|
+
|
|
263
|
+
// #777777 on white = 4.48:1 — fails AA
|
|
264
|
+
const node = createNode({ fg: '#777777', bg: '#FFFFFF', fontSize: 16 });
|
|
265
|
+
const result = checkContrast(node);
|
|
266
|
+
expect(result).toMatchObject({ rule: 'CONTRAST_FAILED' });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('returns null when contrast passes AA and AAA is disabled', () => {
|
|
270
|
+
const { getRuleAction } = require('../utils/getRuleAction');
|
|
271
|
+
getRuleAction.mockReturnValue('PLEASE_FORGIVE_ME');
|
|
272
|
+
|
|
273
|
+
// #757575 on white = 4.61:1 — passes AA
|
|
274
|
+
const node = createNode({ fg: '#757575', bg: '#FFFFFF', fontSize: 16 });
|
|
275
|
+
expect(checkContrast(node)).toBeNull();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AmaNode } from '../../ReactNativeAma.types';
|
|
2
|
+
import { AmaError } from '../types';
|
|
3
|
+
import { shouldIgnoreContrastCheckForDisabledElement } from '../utils/ignoreContrastCheck';
|
|
4
|
+
import { getRuleAction } from '../utils/getRuleAction';
|
|
5
|
+
|
|
6
|
+
function hexToRgb(hex: string) {
|
|
7
|
+
if (!hex || typeof hex !== 'string') {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
|
12
|
+
hex = hex.replace(shorthandRegex, (_, r, g, b) => r + r + g + g + b + b);
|
|
13
|
+
|
|
14
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
15
|
+
return result
|
|
16
|
+
? {
|
|
17
|
+
r: parseInt(result[1], 16),
|
|
18
|
+
g: parseInt(result[2], 16),
|
|
19
|
+
b: parseInt(result[3], 16),
|
|
20
|
+
}
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getLuminance(hex: string) {
|
|
25
|
+
const rgb = hexToRgb(hex);
|
|
26
|
+
if (!rgb) {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
|
|
31
|
+
v /= 255; // Normalize to 0-1 range
|
|
32
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getContrastRatio(fgHex: string, bgHex: string) {
|
|
39
|
+
const lum1 = getLuminance(fgHex);
|
|
40
|
+
const lum2 = getLuminance(bgHex);
|
|
41
|
+
|
|
42
|
+
const lighter = Math.max(lum1, lum2);
|
|
43
|
+
const darker = Math.min(lum1, lum2);
|
|
44
|
+
|
|
45
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const checkContrast = (node: AmaNode): AmaError | null => {
|
|
49
|
+
const { fg, bg, fontSize, isBold, isEnabled } = node;
|
|
50
|
+
|
|
51
|
+
if (!isEnabled && shouldIgnoreContrastCheckForDisabledElement?.()) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* We need to skip node without content as it might be an icon only Button.
|
|
57
|
+
* In this case we can't perform a contrast check as is hard to perform
|
|
58
|
+
* a contrast check on SVGs
|
|
59
|
+
*/
|
|
60
|
+
if (
|
|
61
|
+
!fg ||
|
|
62
|
+
!bg ||
|
|
63
|
+
!node.content ||
|
|
64
|
+
node.type === 'TextInput' ||
|
|
65
|
+
!node.isAccessible
|
|
66
|
+
) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const contrastRatio = getContrastRatio(fg, bg);
|
|
71
|
+
|
|
72
|
+
// Determine if the text is "large" according to WCAG standards.
|
|
73
|
+
// 18pt is considered large, or 14pt if bold.
|
|
74
|
+
const isLargeText =
|
|
75
|
+
fontSize && (fontSize >= 18 || (fontSize >= 14 && isBold));
|
|
76
|
+
|
|
77
|
+
const requiredRatioAA = isLargeText ? 3.0 : 4.5;
|
|
78
|
+
const requiredRatioAAA = isLargeText ? 4.5 : 7.0;
|
|
79
|
+
|
|
80
|
+
const passesAA = contrastRatio >= requiredRatioAA;
|
|
81
|
+
const passesAAA = contrastRatio >= requiredRatioAAA;
|
|
82
|
+
|
|
83
|
+
const aaaAction = getRuleAction?.('CONTRAST_FAILED_AAA');
|
|
84
|
+
const shouldCheckAAA = aaaAction && aaaAction !== 'PLEASE_FORGIVE_ME';
|
|
85
|
+
|
|
86
|
+
if (!passesAAA && shouldCheckAAA) {
|
|
87
|
+
return {
|
|
88
|
+
label: node.ariaLabel,
|
|
89
|
+
viewId: node.viewId,
|
|
90
|
+
rule: 'CONTRAST_FAILED_AAA',
|
|
91
|
+
extra: `The color contrast between the foreground (${fg}) and background (${bg}) is ${contrastRatio.toFixed(
|
|
92
|
+
2
|
|
93
|
+
)}, which is below the required minimum of ${requiredRatioAAA}.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!passesAA) {
|
|
98
|
+
return {
|
|
99
|
+
label: node.ariaLabel,
|
|
100
|
+
viewId: node.viewId,
|
|
101
|
+
rule: 'CONTRAST_FAILED',
|
|
102
|
+
extra: `The color contrast between the foreground (${fg}) and background (${bg}) is ${contrastRatio.toFixed(
|
|
103
|
+
2
|
|
104
|
+
)}, which is below the required minimum of ${requiredRatioAA}.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { AmaNode } from '../../ReactNativeAma.types';
|
|
2
|
+
import { checkImageAlt } from './checkImageAlt';
|
|
3
|
+
|
|
4
|
+
describe('checkImageAlt', () => {
|
|
5
|
+
const createNode = (overrides: Partial<AmaNode> = {}): AmaNode => ({
|
|
6
|
+
type: 'Image',
|
|
7
|
+
viewId: 1,
|
|
8
|
+
bounds: [0, 0, 100, 100],
|
|
9
|
+
hasOnSubmitEditing: false,
|
|
10
|
+
...overrides,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('Image components with missing alt text', () => {
|
|
14
|
+
it('returns error when Image has no ariaLabel', () => {
|
|
15
|
+
const node = createNode({
|
|
16
|
+
type: 'Image',
|
|
17
|
+
ariaLabel: undefined,
|
|
18
|
+
isAccessible: true,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const result = checkImageAlt(node);
|
|
22
|
+
expect(result).toMatchObject({
|
|
23
|
+
rule: 'IMAGE_MISSING_ALT_TEXT',
|
|
24
|
+
viewId: 1,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns error when Image has empty string ariaLabel', () => {
|
|
29
|
+
const node = createNode({
|
|
30
|
+
type: 'Image',
|
|
31
|
+
ariaLabel: '',
|
|
32
|
+
isAccessible: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const result = checkImageAlt(node);
|
|
36
|
+
expect(result).toMatchObject({
|
|
37
|
+
rule: 'IMAGE_MISSING_ALT_TEXT',
|
|
38
|
+
viewId: 1,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns error when Image has no isAccessible prop (defaults to accessible)', () => {
|
|
43
|
+
const node = createNode({
|
|
44
|
+
type: 'Image',
|
|
45
|
+
ariaLabel: undefined,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = checkImageAlt(node);
|
|
49
|
+
expect(result).toMatchObject({
|
|
50
|
+
rule: 'IMAGE_MISSING_ALT_TEXT',
|
|
51
|
+
viewId: 1,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('Image components with valid alt text', () => {
|
|
57
|
+
it('returns null when Image has valid ariaLabel', () => {
|
|
58
|
+
const node = createNode({
|
|
59
|
+
type: 'Image',
|
|
60
|
+
ariaLabel: 'Product photo',
|
|
61
|
+
isAccessible: true,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = checkImageAlt(node);
|
|
65
|
+
expect(result).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns null when Image has descriptive ariaLabel', () => {
|
|
69
|
+
const node = createNode({
|
|
70
|
+
type: 'Image',
|
|
71
|
+
ariaLabel: 'Icon for settings menu',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = checkImageAlt(node);
|
|
75
|
+
expect(result).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Non-accessible images', () => {
|
|
80
|
+
it('returns null when Image is marked non-accessible (decorative)', () => {
|
|
81
|
+
const node = createNode({
|
|
82
|
+
type: 'Image',
|
|
83
|
+
ariaLabel: undefined,
|
|
84
|
+
isAccessible: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = checkImageAlt(node);
|
|
88
|
+
expect(result).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null when decorative image with empty ariaLabel', () => {
|
|
92
|
+
const node = createNode({
|
|
93
|
+
type: 'Image',
|
|
94
|
+
ariaLabel: '',
|
|
95
|
+
isAccessible: false,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const result = checkImageAlt(node);
|
|
99
|
+
expect(result).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('Error details', () => {
|
|
104
|
+
it('includes correct viewId in error', () => {
|
|
105
|
+
const node = createNode({
|
|
106
|
+
type: 'Image',
|
|
107
|
+
viewId: 99999,
|
|
108
|
+
ariaLabel: undefined,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = checkImageAlt(node);
|
|
112
|
+
expect(result?.viewId).toBe(99999);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('includes correct rule in error', () => {
|
|
116
|
+
const node = createNode({
|
|
117
|
+
type: 'Image',
|
|
118
|
+
ariaLabel: undefined,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const result = checkImageAlt(node);
|
|
122
|
+
expect(result?.rule).toBe('IMAGE_MISSING_ALT_TEXT');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AmaNode } from '../../ReactNativeAma.types';
|
|
2
|
+
import { AmaError } from '../types';
|
|
3
|
+
|
|
4
|
+
export const checkImageAlt = (node: AmaNode): AmaError | null => {
|
|
5
|
+
if (node.type !== 'Image') {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (node.isAccessible === false) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { ariaLabel } = node;
|
|
14
|
+
|
|
15
|
+
if (!ariaLabel?.trim()) {
|
|
16
|
+
return {
|
|
17
|
+
rule: 'IMAGE_MISSING_ALT_TEXT',
|
|
18
|
+
viewId: node.viewId,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return null;
|
|
23
|
+
};
|