@croacroa/react-native-template 1.0.0 → 2.0.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/.github/workflows/ci.yml +187 -184
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/CHANGELOG.md +106 -106
- package/CONTRIBUTING.md +377 -377
- package/README.md +399 -399
- package/__tests__/components/snapshots.test.tsx +131 -0
- package/__tests__/integration/auth-api.test.tsx +227 -0
- package/__tests__/performance/VirtualizedList.perf.test.tsx +362 -0
- package/app/(public)/onboarding.tsx +5 -5
- package/app.config.ts +45 -2
- package/assets/images/.gitkeep +7 -7
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/SuspenseBoundary.tsx +357 -0
- package/components/providers/index.ts +13 -0
- package/components/ui/Avatar.tsx +316 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Select.tsx +240 -240
- package/components/ui/VirtualizedList.tsx +285 -0
- package/components/ui/index.ts +23 -18
- package/constants/config.ts +97 -54
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/hooks/index.ts +27 -25
- package/hooks/useApi.ts +102 -5
- package/hooks/useAuth.tsx +82 -0
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useMFA.ts +499 -0
- package/hooks/useNotifications.ts +39 -0
- package/hooks/useOffline.ts +32 -2
- package/hooks/usePerformance.ts +434 -434
- package/hooks/useTheme.tsx +76 -0
- package/hooks/useUpdates.ts +358 -358
- package/i18n/index.ts +194 -77
- package/i18n/locales/ar.json +101 -0
- package/i18n/locales/de.json +101 -0
- package/i18n/locales/en.json +101 -101
- package/i18n/locales/es.json +101 -0
- package/i18n/locales/fr.json +101 -101
- package/jest.config.js +4 -4
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -0
- package/maestro/flows/mfa-setup.yaml +86 -0
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -0
- package/maestro/flows/offline-sync.yaml +128 -0
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +175 -170
- package/services/analytics.ts +428 -428
- package/services/api.ts +340 -340
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +626 -0
- package/services/index.ts +54 -22
- package/services/security.ts +229 -0
- package/tailwind.config.js +47 -47
- package/utils/accessibility.ts +446 -446
- package/utils/index.ts +52 -43
- package/utils/withAccessibility.tsx +272 -0
package/utils/accessibility.ts
CHANGED
|
@@ -1,446 +1,446 @@
|
|
|
1
|
-
import { AccessibilityInfo, AccessibilityRole } from "react-native";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
3
|
-
|
|
4
|
-
// ============================================================================
|
|
5
|
-
// Types
|
|
6
|
-
// ============================================================================
|
|
7
|
-
|
|
8
|
-
export interface AccessibilityProps {
|
|
9
|
-
/**
|
|
10
|
-
* A brief description of the element
|
|
11
|
-
*/
|
|
12
|
-
accessibilityLabel?: string;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Additional context about what will happen when the element is activated
|
|
16
|
-
*/
|
|
17
|
-
accessibilityHint?: string;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The role of the element (button, link, header, etc.)
|
|
21
|
-
*/
|
|
22
|
-
accessibilityRole?: AccessibilityRole;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* State of the element (selected, disabled, checked, etc.)
|
|
26
|
-
*/
|
|
27
|
-
accessibilityState?: {
|
|
28
|
-
disabled?: boolean;
|
|
29
|
-
selected?: boolean;
|
|
30
|
-
checked?: boolean | "mixed";
|
|
31
|
-
busy?: boolean;
|
|
32
|
-
expanded?: boolean;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Value for sliders, progress bars, etc.
|
|
37
|
-
*/
|
|
38
|
-
accessibilityValue?: {
|
|
39
|
-
min?: number;
|
|
40
|
-
max?: number;
|
|
41
|
-
now?: number;
|
|
42
|
-
text?: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Whether the element is accessible
|
|
47
|
-
*/
|
|
48
|
-
accessible?: boolean;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Test ID for testing
|
|
52
|
-
*/
|
|
53
|
-
testID?: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ============================================================================
|
|
57
|
-
// Accessibility Builders
|
|
58
|
-
// ============================================================================
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Build accessibility props for a button
|
|
62
|
-
*/
|
|
63
|
-
export function buttonA11y(
|
|
64
|
-
label: string,
|
|
65
|
-
options?: {
|
|
66
|
-
hint?: string;
|
|
67
|
-
disabled?: boolean;
|
|
68
|
-
loading?: boolean;
|
|
69
|
-
testID?: string;
|
|
70
|
-
}
|
|
71
|
-
): AccessibilityProps {
|
|
72
|
-
return {
|
|
73
|
-
accessible: true,
|
|
74
|
-
accessibilityRole: "button",
|
|
75
|
-
accessibilityLabel: label,
|
|
76
|
-
accessibilityHint: options?.hint,
|
|
77
|
-
accessibilityState: {
|
|
78
|
-
disabled: options?.disabled || options?.loading,
|
|
79
|
-
busy: options?.loading,
|
|
80
|
-
},
|
|
81
|
-
testID: options?.testID,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Build accessibility props for a link
|
|
87
|
-
*/
|
|
88
|
-
export function linkA11y(
|
|
89
|
-
label: string,
|
|
90
|
-
options?: {
|
|
91
|
-
hint?: string;
|
|
92
|
-
testID?: string;
|
|
93
|
-
}
|
|
94
|
-
): AccessibilityProps {
|
|
95
|
-
return {
|
|
96
|
-
accessible: true,
|
|
97
|
-
accessibilityRole: "link",
|
|
98
|
-
accessibilityLabel: label,
|
|
99
|
-
accessibilityHint: options?.hint || "Double tap to open",
|
|
100
|
-
testID: options?.testID,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Build accessibility props for a text input
|
|
106
|
-
*/
|
|
107
|
-
export function inputA11y(
|
|
108
|
-
label: string,
|
|
109
|
-
options?: {
|
|
110
|
-
hint?: string;
|
|
111
|
-
error?: string;
|
|
112
|
-
required?: boolean;
|
|
113
|
-
testID?: string;
|
|
114
|
-
}
|
|
115
|
-
): AccessibilityProps {
|
|
116
|
-
let accessibilityLabel = label;
|
|
117
|
-
if (options?.required) {
|
|
118
|
-
accessibilityLabel += ", required";
|
|
119
|
-
}
|
|
120
|
-
if (options?.error) {
|
|
121
|
-
accessibilityLabel += `, error: ${options.error}`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
accessible: true,
|
|
126
|
-
accessibilityLabel,
|
|
127
|
-
accessibilityHint: options?.hint || "Double tap to edit",
|
|
128
|
-
testID: options?.testID,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Build accessibility props for a checkbox/switch
|
|
134
|
-
*/
|
|
135
|
-
export function toggleA11y(
|
|
136
|
-
label: string,
|
|
137
|
-
checked: boolean,
|
|
138
|
-
options?: {
|
|
139
|
-
hint?: string;
|
|
140
|
-
disabled?: boolean;
|
|
141
|
-
testID?: string;
|
|
142
|
-
}
|
|
143
|
-
): AccessibilityProps {
|
|
144
|
-
return {
|
|
145
|
-
accessible: true,
|
|
146
|
-
accessibilityRole: "checkbox",
|
|
147
|
-
accessibilityLabel: label,
|
|
148
|
-
accessibilityHint:
|
|
149
|
-
options?.hint || `Double tap to ${checked ? "uncheck" : "check"}`,
|
|
150
|
-
accessibilityState: {
|
|
151
|
-
checked,
|
|
152
|
-
disabled: options?.disabled,
|
|
153
|
-
},
|
|
154
|
-
testID: options?.testID,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Build accessibility props for a header
|
|
160
|
-
*/
|
|
161
|
-
export function headerA11y(
|
|
162
|
-
label: string,
|
|
163
|
-
level: 1 | 2 | 3 | 4 | 5 | 6 = 1
|
|
164
|
-
): AccessibilityProps {
|
|
165
|
-
return {
|
|
166
|
-
accessible: true,
|
|
167
|
-
accessibilityRole: "header",
|
|
168
|
-
accessibilityLabel: `${label}, heading level ${level}`,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Build accessibility props for an image
|
|
174
|
-
*/
|
|
175
|
-
export function imageA11y(
|
|
176
|
-
description: string,
|
|
177
|
-
options?: {
|
|
178
|
-
isDecorative?: boolean;
|
|
179
|
-
testID?: string;
|
|
180
|
-
}
|
|
181
|
-
): AccessibilityProps {
|
|
182
|
-
if (options?.isDecorative) {
|
|
183
|
-
return {
|
|
184
|
-
accessible: false,
|
|
185
|
-
accessibilityElementsHidden: true,
|
|
186
|
-
} as AccessibilityProps;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
accessible: true,
|
|
191
|
-
accessibilityRole: "image",
|
|
192
|
-
accessibilityLabel: description,
|
|
193
|
-
testID: options?.testID,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Build accessibility props for a list item
|
|
199
|
-
*/
|
|
200
|
-
export function listItemA11y(
|
|
201
|
-
label: string,
|
|
202
|
-
position: number,
|
|
203
|
-
total: number,
|
|
204
|
-
options?: {
|
|
205
|
-
hint?: string;
|
|
206
|
-
selected?: boolean;
|
|
207
|
-
testID?: string;
|
|
208
|
-
}
|
|
209
|
-
): AccessibilityProps {
|
|
210
|
-
return {
|
|
211
|
-
accessible: true,
|
|
212
|
-
accessibilityLabel: `${label}, ${position} of ${total}`,
|
|
213
|
-
accessibilityHint: options?.hint,
|
|
214
|
-
accessibilityState: {
|
|
215
|
-
selected: options?.selected,
|
|
216
|
-
},
|
|
217
|
-
testID: options?.testID,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Build accessibility props for a progress indicator
|
|
223
|
-
*/
|
|
224
|
-
export function progressA11y(
|
|
225
|
-
label: string,
|
|
226
|
-
value: number,
|
|
227
|
-
options?: {
|
|
228
|
-
min?: number;
|
|
229
|
-
max?: number;
|
|
230
|
-
testID?: string;
|
|
231
|
-
}
|
|
232
|
-
): AccessibilityProps {
|
|
233
|
-
const min = options?.min ?? 0;
|
|
234
|
-
const max = options?.max ?? 100;
|
|
235
|
-
const percentage = Math.round(((value - min) / (max - min)) * 100);
|
|
236
|
-
|
|
237
|
-
return {
|
|
238
|
-
accessible: true,
|
|
239
|
-
accessibilityRole: "progressbar",
|
|
240
|
-
accessibilityLabel: `${label}, ${percentage}% complete`,
|
|
241
|
-
accessibilityValue: {
|
|
242
|
-
min,
|
|
243
|
-
max,
|
|
244
|
-
now: value,
|
|
245
|
-
text: `${percentage}%`,
|
|
246
|
-
},
|
|
247
|
-
testID: options?.testID,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Build accessibility props for a tab
|
|
253
|
-
*/
|
|
254
|
-
export function tabA11y(
|
|
255
|
-
label: string,
|
|
256
|
-
selected: boolean,
|
|
257
|
-
position: number,
|
|
258
|
-
total: number,
|
|
259
|
-
options?: {
|
|
260
|
-
hint?: string;
|
|
261
|
-
testID?: string;
|
|
262
|
-
}
|
|
263
|
-
): AccessibilityProps {
|
|
264
|
-
return {
|
|
265
|
-
accessible: true,
|
|
266
|
-
accessibilityRole: "tab",
|
|
267
|
-
accessibilityLabel: `${label}, tab ${position} of ${total}`,
|
|
268
|
-
accessibilityHint: options?.hint,
|
|
269
|
-
accessibilityState: {
|
|
270
|
-
selected,
|
|
271
|
-
},
|
|
272
|
-
testID: options?.testID,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Build accessibility props for an alert/notification
|
|
278
|
-
*/
|
|
279
|
-
export function alertA11y(
|
|
280
|
-
message: string,
|
|
281
|
-
options?: {
|
|
282
|
-
type?: "info" | "success" | "warning" | "error";
|
|
283
|
-
testID?: string;
|
|
284
|
-
}
|
|
285
|
-
): AccessibilityProps {
|
|
286
|
-
const typeLabel = options?.type ? `${options.type}: ` : "";
|
|
287
|
-
|
|
288
|
-
return {
|
|
289
|
-
accessible: true,
|
|
290
|
-
accessibilityRole: "alert",
|
|
291
|
-
accessibilityLabel: `${typeLabel}${message}`,
|
|
292
|
-
accessibilityLiveRegion: "polite",
|
|
293
|
-
testID: options?.testID,
|
|
294
|
-
} as AccessibilityProps;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ============================================================================
|
|
298
|
-
// Hooks
|
|
299
|
-
// ============================================================================
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Hook to check if screen reader is enabled
|
|
303
|
-
*/
|
|
304
|
-
export function useScreenReader(): boolean {
|
|
305
|
-
const [isEnabled, setIsEnabled] = useState(false);
|
|
306
|
-
|
|
307
|
-
useEffect(() => {
|
|
308
|
-
AccessibilityInfo.isScreenReaderEnabled().then(setIsEnabled);
|
|
309
|
-
|
|
310
|
-
const subscription = AccessibilityInfo.addEventListener(
|
|
311
|
-
"screenReaderChanged",
|
|
312
|
-
setIsEnabled
|
|
313
|
-
);
|
|
314
|
-
|
|
315
|
-
return () => subscription.remove();
|
|
316
|
-
}, []);
|
|
317
|
-
|
|
318
|
-
return isEnabled;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Hook to check if reduce motion is enabled
|
|
323
|
-
*/
|
|
324
|
-
export function useReduceMotion(): boolean {
|
|
325
|
-
const [isEnabled, setIsEnabled] = useState(false);
|
|
326
|
-
|
|
327
|
-
useEffect(() => {
|
|
328
|
-
AccessibilityInfo.isReduceMotionEnabled().then(setIsEnabled);
|
|
329
|
-
|
|
330
|
-
const subscription = AccessibilityInfo.addEventListener(
|
|
331
|
-
"reduceMotionChanged",
|
|
332
|
-
setIsEnabled
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
return () => subscription.remove();
|
|
336
|
-
}, []);
|
|
337
|
-
|
|
338
|
-
return isEnabled;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Hook to check if bold text is enabled
|
|
343
|
-
*/
|
|
344
|
-
export function useBoldText(): boolean {
|
|
345
|
-
const [isEnabled, setIsEnabled] = useState(false);
|
|
346
|
-
|
|
347
|
-
useEffect(() => {
|
|
348
|
-
AccessibilityInfo.isBoldTextEnabled().then(setIsEnabled);
|
|
349
|
-
|
|
350
|
-
const subscription = AccessibilityInfo.addEventListener(
|
|
351
|
-
"boldTextChanged",
|
|
352
|
-
setIsEnabled
|
|
353
|
-
);
|
|
354
|
-
|
|
355
|
-
return () => subscription.remove();
|
|
356
|
-
}, []);
|
|
357
|
-
|
|
358
|
-
return isEnabled;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Hook to get all accessibility preferences
|
|
363
|
-
*/
|
|
364
|
-
export function useAccessibilityPreferences() {
|
|
365
|
-
const isScreenReaderEnabled = useScreenReader();
|
|
366
|
-
const isReduceMotionEnabled = useReduceMotion();
|
|
367
|
-
const isBoldTextEnabled = useBoldText();
|
|
368
|
-
|
|
369
|
-
return {
|
|
370
|
-
isScreenReaderEnabled,
|
|
371
|
-
isReduceMotionEnabled,
|
|
372
|
-
isBoldTextEnabled,
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// ============================================================================
|
|
377
|
-
// Utilities
|
|
378
|
-
// ============================================================================
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Announce a message to screen readers
|
|
382
|
-
*/
|
|
383
|
-
export function announce(message: string): void {
|
|
384
|
-
AccessibilityInfo.announceForAccessibility(message);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Set focus to a specific element (requires a ref)
|
|
389
|
-
*/
|
|
390
|
-
export function setAccessibilityFocus(ref: React.RefObject<unknown>): void {
|
|
391
|
-
if (ref.current) {
|
|
392
|
-
AccessibilityInfo.setAccessibilityFocus(ref.current);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Format a price for accessibility
|
|
398
|
-
*/
|
|
399
|
-
export function formatPriceA11y(
|
|
400
|
-
amount: number,
|
|
401
|
-
currency = "EUR",
|
|
402
|
-
locale = "fr-FR"
|
|
403
|
-
): string {
|
|
404
|
-
const formatted = new Intl.NumberFormat(locale, {
|
|
405
|
-
style: "currency",
|
|
406
|
-
currency,
|
|
407
|
-
}).format(amount);
|
|
408
|
-
|
|
409
|
-
// Convert to speakable format
|
|
410
|
-
return formatted
|
|
411
|
-
.replace("€", "euros")
|
|
412
|
-
.replace("$", "dollars")
|
|
413
|
-
.replace("£", "pounds");
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Format a date for accessibility
|
|
418
|
-
*/
|
|
419
|
-
export function formatDateA11y(date: Date | string, locale = "fr-FR"): string {
|
|
420
|
-
const d = typeof date === "string" ? new Date(date) : date;
|
|
421
|
-
|
|
422
|
-
return d.toLocaleDateString(locale, {
|
|
423
|
-
weekday: "long",
|
|
424
|
-
year: "numeric",
|
|
425
|
-
month: "long",
|
|
426
|
-
day: "numeric",
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Format a duration for accessibility
|
|
432
|
-
*/
|
|
433
|
-
export function formatDurationA11y(seconds: number): string {
|
|
434
|
-
const hours = Math.floor(seconds / 3600);
|
|
435
|
-
const minutes = Math.floor((seconds % 3600) / 60);
|
|
436
|
-
const secs = seconds % 60;
|
|
437
|
-
|
|
438
|
-
const parts = [];
|
|
439
|
-
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
|
|
440
|
-
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
|
|
441
|
-
if (secs > 0 || parts.length === 0) {
|
|
442
|
-
parts.push(`${secs} second${secs !== 1 ? "s" : ""}`);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
return parts.join(", ");
|
|
446
|
-
}
|
|
1
|
+
import { AccessibilityInfo, AccessibilityRole } from "react-native";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Types
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
export interface AccessibilityProps {
|
|
9
|
+
/**
|
|
10
|
+
* A brief description of the element
|
|
11
|
+
*/
|
|
12
|
+
accessibilityLabel?: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Additional context about what will happen when the element is activated
|
|
16
|
+
*/
|
|
17
|
+
accessibilityHint?: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The role of the element (button, link, header, etc.)
|
|
21
|
+
*/
|
|
22
|
+
accessibilityRole?: AccessibilityRole;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* State of the element (selected, disabled, checked, etc.)
|
|
26
|
+
*/
|
|
27
|
+
accessibilityState?: {
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
selected?: boolean;
|
|
30
|
+
checked?: boolean | "mixed";
|
|
31
|
+
busy?: boolean;
|
|
32
|
+
expanded?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Value for sliders, progress bars, etc.
|
|
37
|
+
*/
|
|
38
|
+
accessibilityValue?: {
|
|
39
|
+
min?: number;
|
|
40
|
+
max?: number;
|
|
41
|
+
now?: number;
|
|
42
|
+
text?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether the element is accessible
|
|
47
|
+
*/
|
|
48
|
+
accessible?: boolean;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Test ID for testing
|
|
52
|
+
*/
|
|
53
|
+
testID?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Accessibility Builders
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build accessibility props for a button
|
|
62
|
+
*/
|
|
63
|
+
export function buttonA11y(
|
|
64
|
+
label: string,
|
|
65
|
+
options?: {
|
|
66
|
+
hint?: string;
|
|
67
|
+
disabled?: boolean;
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
testID?: string;
|
|
70
|
+
}
|
|
71
|
+
): AccessibilityProps {
|
|
72
|
+
return {
|
|
73
|
+
accessible: true,
|
|
74
|
+
accessibilityRole: "button",
|
|
75
|
+
accessibilityLabel: label,
|
|
76
|
+
accessibilityHint: options?.hint,
|
|
77
|
+
accessibilityState: {
|
|
78
|
+
disabled: options?.disabled || options?.loading,
|
|
79
|
+
busy: options?.loading,
|
|
80
|
+
},
|
|
81
|
+
testID: options?.testID,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build accessibility props for a link
|
|
87
|
+
*/
|
|
88
|
+
export function linkA11y(
|
|
89
|
+
label: string,
|
|
90
|
+
options?: {
|
|
91
|
+
hint?: string;
|
|
92
|
+
testID?: string;
|
|
93
|
+
}
|
|
94
|
+
): AccessibilityProps {
|
|
95
|
+
return {
|
|
96
|
+
accessible: true,
|
|
97
|
+
accessibilityRole: "link",
|
|
98
|
+
accessibilityLabel: label,
|
|
99
|
+
accessibilityHint: options?.hint || "Double tap to open",
|
|
100
|
+
testID: options?.testID,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build accessibility props for a text input
|
|
106
|
+
*/
|
|
107
|
+
export function inputA11y(
|
|
108
|
+
label: string,
|
|
109
|
+
options?: {
|
|
110
|
+
hint?: string;
|
|
111
|
+
error?: string;
|
|
112
|
+
required?: boolean;
|
|
113
|
+
testID?: string;
|
|
114
|
+
}
|
|
115
|
+
): AccessibilityProps {
|
|
116
|
+
let accessibilityLabel = label;
|
|
117
|
+
if (options?.required) {
|
|
118
|
+
accessibilityLabel += ", required";
|
|
119
|
+
}
|
|
120
|
+
if (options?.error) {
|
|
121
|
+
accessibilityLabel += `, error: ${options.error}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
accessible: true,
|
|
126
|
+
accessibilityLabel,
|
|
127
|
+
accessibilityHint: options?.hint || "Double tap to edit",
|
|
128
|
+
testID: options?.testID,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build accessibility props for a checkbox/switch
|
|
134
|
+
*/
|
|
135
|
+
export function toggleA11y(
|
|
136
|
+
label: string,
|
|
137
|
+
checked: boolean,
|
|
138
|
+
options?: {
|
|
139
|
+
hint?: string;
|
|
140
|
+
disabled?: boolean;
|
|
141
|
+
testID?: string;
|
|
142
|
+
}
|
|
143
|
+
): AccessibilityProps {
|
|
144
|
+
return {
|
|
145
|
+
accessible: true,
|
|
146
|
+
accessibilityRole: "checkbox",
|
|
147
|
+
accessibilityLabel: label,
|
|
148
|
+
accessibilityHint:
|
|
149
|
+
options?.hint || `Double tap to ${checked ? "uncheck" : "check"}`,
|
|
150
|
+
accessibilityState: {
|
|
151
|
+
checked,
|
|
152
|
+
disabled: options?.disabled,
|
|
153
|
+
},
|
|
154
|
+
testID: options?.testID,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build accessibility props for a header
|
|
160
|
+
*/
|
|
161
|
+
export function headerA11y(
|
|
162
|
+
label: string,
|
|
163
|
+
level: 1 | 2 | 3 | 4 | 5 | 6 = 1
|
|
164
|
+
): AccessibilityProps {
|
|
165
|
+
return {
|
|
166
|
+
accessible: true,
|
|
167
|
+
accessibilityRole: "header",
|
|
168
|
+
accessibilityLabel: `${label}, heading level ${level}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build accessibility props for an image
|
|
174
|
+
*/
|
|
175
|
+
export function imageA11y(
|
|
176
|
+
description: string,
|
|
177
|
+
options?: {
|
|
178
|
+
isDecorative?: boolean;
|
|
179
|
+
testID?: string;
|
|
180
|
+
}
|
|
181
|
+
): AccessibilityProps {
|
|
182
|
+
if (options?.isDecorative) {
|
|
183
|
+
return {
|
|
184
|
+
accessible: false,
|
|
185
|
+
accessibilityElementsHidden: true,
|
|
186
|
+
} as AccessibilityProps;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
accessible: true,
|
|
191
|
+
accessibilityRole: "image",
|
|
192
|
+
accessibilityLabel: description,
|
|
193
|
+
testID: options?.testID,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build accessibility props for a list item
|
|
199
|
+
*/
|
|
200
|
+
export function listItemA11y(
|
|
201
|
+
label: string,
|
|
202
|
+
position: number,
|
|
203
|
+
total: number,
|
|
204
|
+
options?: {
|
|
205
|
+
hint?: string;
|
|
206
|
+
selected?: boolean;
|
|
207
|
+
testID?: string;
|
|
208
|
+
}
|
|
209
|
+
): AccessibilityProps {
|
|
210
|
+
return {
|
|
211
|
+
accessible: true,
|
|
212
|
+
accessibilityLabel: `${label}, ${position} of ${total}`,
|
|
213
|
+
accessibilityHint: options?.hint,
|
|
214
|
+
accessibilityState: {
|
|
215
|
+
selected: options?.selected,
|
|
216
|
+
},
|
|
217
|
+
testID: options?.testID,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build accessibility props for a progress indicator
|
|
223
|
+
*/
|
|
224
|
+
export function progressA11y(
|
|
225
|
+
label: string,
|
|
226
|
+
value: number,
|
|
227
|
+
options?: {
|
|
228
|
+
min?: number;
|
|
229
|
+
max?: number;
|
|
230
|
+
testID?: string;
|
|
231
|
+
}
|
|
232
|
+
): AccessibilityProps {
|
|
233
|
+
const min = options?.min ?? 0;
|
|
234
|
+
const max = options?.max ?? 100;
|
|
235
|
+
const percentage = Math.round(((value - min) / (max - min)) * 100);
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
accessible: true,
|
|
239
|
+
accessibilityRole: "progressbar",
|
|
240
|
+
accessibilityLabel: `${label}, ${percentage}% complete`,
|
|
241
|
+
accessibilityValue: {
|
|
242
|
+
min,
|
|
243
|
+
max,
|
|
244
|
+
now: value,
|
|
245
|
+
text: `${percentage}%`,
|
|
246
|
+
},
|
|
247
|
+
testID: options?.testID,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build accessibility props for a tab
|
|
253
|
+
*/
|
|
254
|
+
export function tabA11y(
|
|
255
|
+
label: string,
|
|
256
|
+
selected: boolean,
|
|
257
|
+
position: number,
|
|
258
|
+
total: number,
|
|
259
|
+
options?: {
|
|
260
|
+
hint?: string;
|
|
261
|
+
testID?: string;
|
|
262
|
+
}
|
|
263
|
+
): AccessibilityProps {
|
|
264
|
+
return {
|
|
265
|
+
accessible: true,
|
|
266
|
+
accessibilityRole: "tab",
|
|
267
|
+
accessibilityLabel: `${label}, tab ${position} of ${total}`,
|
|
268
|
+
accessibilityHint: options?.hint,
|
|
269
|
+
accessibilityState: {
|
|
270
|
+
selected,
|
|
271
|
+
},
|
|
272
|
+
testID: options?.testID,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build accessibility props for an alert/notification
|
|
278
|
+
*/
|
|
279
|
+
export function alertA11y(
|
|
280
|
+
message: string,
|
|
281
|
+
options?: {
|
|
282
|
+
type?: "info" | "success" | "warning" | "error";
|
|
283
|
+
testID?: string;
|
|
284
|
+
}
|
|
285
|
+
): AccessibilityProps {
|
|
286
|
+
const typeLabel = options?.type ? `${options.type}: ` : "";
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
accessible: true,
|
|
290
|
+
accessibilityRole: "alert",
|
|
291
|
+
accessibilityLabel: `${typeLabel}${message}`,
|
|
292
|
+
accessibilityLiveRegion: "polite",
|
|
293
|
+
testID: options?.testID,
|
|
294
|
+
} as AccessibilityProps;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// Hooks
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Hook to check if screen reader is enabled
|
|
303
|
+
*/
|
|
304
|
+
export function useScreenReader(): boolean {
|
|
305
|
+
const [isEnabled, setIsEnabled] = useState(false);
|
|
306
|
+
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
AccessibilityInfo.isScreenReaderEnabled().then(setIsEnabled);
|
|
309
|
+
|
|
310
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
311
|
+
"screenReaderChanged",
|
|
312
|
+
setIsEnabled
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
return () => subscription.remove();
|
|
316
|
+
}, []);
|
|
317
|
+
|
|
318
|
+
return isEnabled;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Hook to check if reduce motion is enabled
|
|
323
|
+
*/
|
|
324
|
+
export function useReduceMotion(): boolean {
|
|
325
|
+
const [isEnabled, setIsEnabled] = useState(false);
|
|
326
|
+
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
AccessibilityInfo.isReduceMotionEnabled().then(setIsEnabled);
|
|
329
|
+
|
|
330
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
331
|
+
"reduceMotionChanged",
|
|
332
|
+
setIsEnabled
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
return () => subscription.remove();
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
return isEnabled;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Hook to check if bold text is enabled
|
|
343
|
+
*/
|
|
344
|
+
export function useBoldText(): boolean {
|
|
345
|
+
const [isEnabled, setIsEnabled] = useState(false);
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
AccessibilityInfo.isBoldTextEnabled().then(setIsEnabled);
|
|
349
|
+
|
|
350
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
351
|
+
"boldTextChanged",
|
|
352
|
+
setIsEnabled
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
return () => subscription.remove();
|
|
356
|
+
}, []);
|
|
357
|
+
|
|
358
|
+
return isEnabled;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Hook to get all accessibility preferences
|
|
363
|
+
*/
|
|
364
|
+
export function useAccessibilityPreferences() {
|
|
365
|
+
const isScreenReaderEnabled = useScreenReader();
|
|
366
|
+
const isReduceMotionEnabled = useReduceMotion();
|
|
367
|
+
const isBoldTextEnabled = useBoldText();
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
isScreenReaderEnabled,
|
|
371
|
+
isReduceMotionEnabled,
|
|
372
|
+
isBoldTextEnabled,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ============================================================================
|
|
377
|
+
// Utilities
|
|
378
|
+
// ============================================================================
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Announce a message to screen readers
|
|
382
|
+
*/
|
|
383
|
+
export function announce(message: string): void {
|
|
384
|
+
AccessibilityInfo.announceForAccessibility(message);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Set focus to a specific element (requires a ref)
|
|
389
|
+
*/
|
|
390
|
+
export function setAccessibilityFocus(ref: React.RefObject<unknown>): void {
|
|
391
|
+
if (ref.current) {
|
|
392
|
+
AccessibilityInfo.setAccessibilityFocus(ref.current);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Format a price for accessibility
|
|
398
|
+
*/
|
|
399
|
+
export function formatPriceA11y(
|
|
400
|
+
amount: number,
|
|
401
|
+
currency = "EUR",
|
|
402
|
+
locale = "fr-FR"
|
|
403
|
+
): string {
|
|
404
|
+
const formatted = new Intl.NumberFormat(locale, {
|
|
405
|
+
style: "currency",
|
|
406
|
+
currency,
|
|
407
|
+
}).format(amount);
|
|
408
|
+
|
|
409
|
+
// Convert to speakable format
|
|
410
|
+
return formatted
|
|
411
|
+
.replace("€", "euros")
|
|
412
|
+
.replace("$", "dollars")
|
|
413
|
+
.replace("£", "pounds");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Format a date for accessibility
|
|
418
|
+
*/
|
|
419
|
+
export function formatDateA11y(date: Date | string, locale = "fr-FR"): string {
|
|
420
|
+
const d = typeof date === "string" ? new Date(date) : date;
|
|
421
|
+
|
|
422
|
+
return d.toLocaleDateString(locale, {
|
|
423
|
+
weekday: "long",
|
|
424
|
+
year: "numeric",
|
|
425
|
+
month: "long",
|
|
426
|
+
day: "numeric",
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Format a duration for accessibility
|
|
432
|
+
*/
|
|
433
|
+
export function formatDurationA11y(seconds: number): string {
|
|
434
|
+
const hours = Math.floor(seconds / 3600);
|
|
435
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
436
|
+
const secs = seconds % 60;
|
|
437
|
+
|
|
438
|
+
const parts = [];
|
|
439
|
+
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
|
|
440
|
+
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
|
|
441
|
+
if (secs > 0 || parts.length === 0) {
|
|
442
|
+
parts.push(`${secs} second${secs !== 1 ? "s" : ""}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return parts.join(", ");
|
|
446
|
+
}
|