@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.
Files changed (179) hide show
  1. package/dist/ReactNativeAma.types.d.ts +66 -0
  2. package/dist/ReactNativeAma.types.js +2 -0
  3. package/dist/ReactNativeAmaModule.d.ts +11 -0
  4. package/dist/ReactNativeAmaModule.js +5 -0
  5. package/dist/ReactNativeAmaModule.web.d.ts +9 -0
  6. package/dist/ReactNativeAmaModule.web.js +27 -0
  7. package/dist/ReactNativeAmaView.d.ts +3 -0
  8. package/dist/ReactNativeAmaView.js +32 -0
  9. package/dist/ReactNativeAmaView.web.d.ts +3 -0
  10. package/dist/ReactNativeAmaView.web.js +34 -0
  11. package/dist/{src/components → components}/AMAProvider.d.ts +4 -2
  12. package/dist/{src/components → components}/AMAProvider.js +19 -45
  13. package/dist/{src/components → components}/AutofocusContainer.d.ts +2 -1
  14. package/dist/{src/components → components}/HideChildrenFromAccessibilityTree.d.ts +1 -1
  15. package/dist/{src/components → components}/HideChildrenFromAccessibilityTree.js +7 -5
  16. package/dist/{src/hooks → hooks}/useFocus.js +10 -3
  17. package/dist/{src/index.d.ts → index.d.ts} +3 -2
  18. package/dist/index.js +38 -0
  19. package/dist/internals/checks/checkAriaLabel.d.ts +3 -0
  20. package/dist/internals/checks/checkAriaLabel.js +15 -0
  21. package/dist/internals/checks/checkAriaRole.d.ts +3 -0
  22. package/dist/internals/checks/checkAriaRole.js +48 -0
  23. package/dist/internals/checks/checkContrast.d.ts +3 -0
  24. package/dist/internals/checks/checkContrast.js +84 -0
  25. package/dist/internals/checks/checkImageAlt.d.ts +3 -0
  26. package/dist/internals/checks/checkImageAlt.js +20 -0
  27. package/dist/internals/checks/checkIsUppercase.d.ts +8 -0
  28. package/dist/internals/checks/checkIsUppercase.js +30 -0
  29. package/dist/internals/checks/checkLongNumber.d.ts +3 -0
  30. package/dist/internals/checks/checkLongNumber.js +31 -0
  31. package/dist/internals/checks/checkMinimumSize.d.ts +3 -0
  32. package/dist/internals/checks/checkMinimumSize.js +22 -0
  33. package/dist/internals/checks/checkTextInput.d.ts +3 -0
  34. package/dist/internals/checks/checkTextInput.js +51 -0
  35. package/dist/internals/checks/performChecks.d.ts +3 -0
  36. package/dist/internals/checks/performChecks.js +23 -0
  37. package/dist/internals/components/AMAErrorOverlay.d.ts +5 -0
  38. package/dist/internals/components/AMAErrorOverlay.js +334 -0
  39. package/dist/internals/components/AMARuleError.d.ts +7 -0
  40. package/dist/internals/components/AMARuleError.js +161 -0
  41. package/dist/internals/config.d.ts +23 -0
  42. package/dist/internals/config.js +33 -0
  43. package/dist/internals/types.d.ts +9 -0
  44. package/dist/internals/types.js +2 -0
  45. package/dist/internals/useAMADev.d.ts +8 -0
  46. package/dist/internals/useAMADev.dev.d.ts +2 -0
  47. package/dist/internals/useAMADev.dev.js +110 -0
  48. package/dist/internals/useAMADev.js +6 -0
  49. package/dist/internals/useAMADevNodeChecks.dev.d.ts +16 -0
  50. package/dist/internals/useAMADevNodeChecks.dev.js +115 -0
  51. package/dist/internals/useAMADevUiInteraction.dev.d.ts +7 -0
  52. package/dist/internals/useAMADevUiInteraction.dev.js +100 -0
  53. package/dist/internals/utils/amaClearHighlight.d.ts +2 -0
  54. package/dist/internals/utils/amaClearHighlight.js +12 -0
  55. package/dist/internals/utils/amaHighlightComponent.d.ts +3 -0
  56. package/dist/internals/utils/amaHighlightComponent.js +51 -0
  57. package/dist/internals/utils/constants.d.ts +3 -0
  58. package/dist/internals/utils/constants.js +32 -0
  59. package/dist/internals/utils/getErrorColor.d.ts +2 -0
  60. package/dist/internals/utils/getErrorColor.js +10 -0
  61. package/dist/internals/utils/getRuleAction.d.ts +2 -0
  62. package/dist/internals/utils/getRuleAction.js +40 -0
  63. package/dist/internals/utils/getRuleErrorInfo.d.ts +7 -0
  64. package/dist/internals/utils/getRuleErrorInfo.js +19 -0
  65. package/dist/internals/utils/ignoreContrastCheck.d.ts +1 -0
  66. package/dist/internals/utils/ignoreContrastCheck.js +15 -0
  67. package/dist/internals/utils/interpolateAnimationStates.d.ts +2 -0
  68. package/dist/internals/utils/interpolateAnimationStates.js +43 -0
  69. package/dist/internals/utils/isA11yLabelAllowed.d.ts +1 -0
  70. package/dist/internals/utils/isA11yLabelAllowed.js +14 -0
  71. package/dist/internals/utils/isRuleDisabled.d.ts +2 -0
  72. package/dist/internals/utils/isRuleDisabled.js +10 -0
  73. package/dist/internals/utils/logError.d.ts +2 -0
  74. package/dist/internals/utils/logError.js +36 -0
  75. package/dist/internals/utils/logFoundIssues.d.ts +2 -0
  76. package/dist/internals/utils/logFoundIssues.js +23 -0
  77. package/dist/internals/utils/logger.d.ts +6 -0
  78. package/dist/internals/utils/logger.js +15 -0
  79. package/dist/internals/utils/minimumTouchableSize.d.ts +1 -0
  80. package/dist/internals/utils/minimumTouchableSize.js +9 -0
  81. package/dist/internals/utils/platformHelpers.d.ts +2 -0
  82. package/dist/internals/utils/platformHelpers.js +6 -0
  83. package/dist/internals/utils/rules.d.ts +22 -0
  84. package/dist/internals/utils/rules.js +253 -0
  85. package/package.json +61 -18
  86. package/src/ReactNativeAma.types.ts +70 -0
  87. package/src/ReactNativeAmaModule.ts +18 -0
  88. package/src/ReactNativeAmaModule.web.ts +14 -0
  89. package/src/ReactNativeAmaView.tsx +9 -0
  90. package/src/ReactNativeAmaView.web.tsx +15 -0
  91. package/src/__mocks__/ReactNativeAmaModule.ts +9 -0
  92. package/src/components/AMAProvider.integration.test.tsx +3 -96
  93. package/src/components/AMAProvider.test.tsx +9 -4
  94. package/src/components/AMAProvider.tsx +40 -76
  95. package/src/components/AutofocusContainer.test.tsx +4 -5
  96. package/src/components/AutofocusContainer.tsx +20 -5
  97. package/src/components/HideChildrenFromAccessibilityTree.test.tsx +23 -39
  98. package/src/components/HideChildrenFromAccessibilityTree.tsx +12 -11
  99. package/src/hooks/useFocus.test.ts +20 -20
  100. package/src/hooks/useFocus.ts +14 -6
  101. package/src/hooks/useTimedAction.android.test.ts +0 -1
  102. package/src/hooks/useTimedAction.ios.test.ts +0 -1
  103. package/src/hooks/useTimedAction.ts +0 -1
  104. package/src/index.ts +4 -2
  105. package/src/internals/checks/checkAriaLabel.test.ts +165 -0
  106. package/src/internals/checks/checkAriaLabel.ts +16 -0
  107. package/src/internals/checks/checkAriaRole.test.ts +429 -0
  108. package/src/internals/checks/checkAriaRole.ts +60 -0
  109. package/src/internals/checks/checkContrast.test.ts +278 -0
  110. package/src/internals/checks/checkContrast.ts +109 -0
  111. package/src/internals/checks/checkImageAlt.test.ts +125 -0
  112. package/src/internals/checks/checkImageAlt.ts +23 -0
  113. package/src/internals/checks/checkIsUppercase.test.ts +399 -0
  114. package/src/internals/checks/checkIsUppercase.ts +46 -0
  115. package/src/internals/checks/checkLongNumber.test.ts +111 -0
  116. package/src/internals/checks/checkLongNumber.ts +34 -0
  117. package/src/internals/checks/checkMinimumSize.test.ts +67 -0
  118. package/src/internals/checks/checkMinimumSize.ts +25 -0
  119. package/src/internals/checks/checkTextInput.ts +65 -0
  120. package/src/internals/checks/performChecks.test.ts +171 -0
  121. package/src/internals/checks/performChecks.ts +24 -0
  122. package/src/internals/components/AMAErrorOverlay.test.tsx +139 -0
  123. package/src/internals/components/AMAErrorOverlay.tsx +451 -0
  124. package/src/internals/components/AMARuleError.test.tsx +93 -0
  125. package/src/internals/components/AMARuleError.tsx +225 -0
  126. package/src/internals/config.ts +58 -0
  127. package/src/internals/types.ts +11 -0
  128. package/src/internals/useAMADev.dev.ts +153 -0
  129. package/src/internals/useAMADev.test.ts +260 -0
  130. package/src/internals/useAMADev.ts +17 -0
  131. package/src/internals/useAMADevNodeChecks.dev.ts +178 -0
  132. package/src/internals/useAMADevUiInteraction.dev.ts +164 -0
  133. package/src/internals/utils/amaClearHighlight.test.ts +21 -0
  134. package/src/internals/utils/amaClearHighlight.ts +8 -0
  135. package/src/internals/utils/amaHighlightComponent.test.ts +74 -0
  136. package/src/internals/utils/amaHighlightComponent.ts +46 -0
  137. package/src/internals/utils/constants.ts +33 -0
  138. package/src/internals/utils/devMode.test.ts +99 -0
  139. package/src/internals/utils/getErrorColor.test.ts +18 -0
  140. package/src/internals/utils/getErrorColor.ts +8 -0
  141. package/src/internals/utils/getRuleAction.ts +13 -0
  142. package/src/internals/utils/getRuleErrorInfo.test.ts +59 -0
  143. package/src/internals/utils/getRuleErrorInfo.ts +26 -0
  144. package/src/internals/utils/ignoreContrastCheck.test.ts +34 -0
  145. package/src/internals/utils/ignoreContrastCheck.ts +11 -0
  146. package/src/internals/utils/interpolateAnimation.test.ts +176 -0
  147. package/src/internals/utils/interpolateAnimationStates.ts +55 -0
  148. package/src/internals/utils/isA11yLabelAllowed.test.ts +39 -0
  149. package/src/internals/utils/isA11yLabelAllowed.ts +10 -0
  150. package/src/internals/utils/isRuleDisabled.test.ts +35 -0
  151. package/src/internals/utils/isRuleDisabled.ts +10 -0
  152. package/src/internals/utils/logError.test.ts +148 -0
  153. package/src/internals/utils/logError.ts +41 -0
  154. package/src/internals/utils/logFoundIssues.test.ts +98 -0
  155. package/src/internals/utils/logFoundIssues.ts +22 -0
  156. package/src/internals/utils/logger.test.ts +26 -0
  157. package/src/internals/utils/logger.ts +15 -0
  158. package/src/internals/utils/loggerMethods.test.ts +38 -0
  159. package/src/internals/utils/minimumTouchableSize.test.ts +28 -0
  160. package/src/internals/utils/minimumTouchableSize.tsx +9 -0
  161. package/src/internals/utils/platformHelpers.test.ts +11 -0
  162. package/src/internals/utils/platformHelpers.ts +4 -0
  163. package/src/internals/utils/rules.ts +363 -0
  164. package/dist/scripts/create-config.d.ts +0 -1
  165. package/dist/scripts/create-config.js +0 -23
  166. package/dist/src/hooks/useButtonChecks.d.ts +0 -6
  167. package/dist/src/hooks/useButtonChecks.js +0 -51
  168. package/dist/src/hooks/useChecks.d.ts +0 -15
  169. package/dist/src/hooks/useChecks.js +0 -152
  170. package/dist/src/index.js +0 -20
  171. package/src/hooks/useButtonChecks.ts +0 -73
  172. package/src/hooks/useChecks.test.ts +0 -214
  173. package/src/hooks/useChecks.ts +0 -207
  174. /package/dist/{src/components → components}/AutofocusContainer.js +0 -0
  175. /package/dist/{src/hooks → hooks}/useFocus.d.ts +0 -0
  176. /package/dist/{src/hooks → hooks}/useTimedAction.d.ts +0 -0
  177. /package/dist/{src/hooks → hooks}/useTimedAction.js +0 -0
  178. /package/dist/{src/utils → utils}/numerify.d.ts +0 -0
  179. /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
+ };