@croacroa/react-native-template 2.1.0 → 3.2.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/.env.example +5 -0
- package/.eslintrc.js +8 -0
- package/.github/workflows/ci.yml +187 -187
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/.github/workflows/npm-publish.yml +57 -0
- package/CHANGELOG.md +195 -106
- package/CONTRIBUTING.md +377 -377
- package/LICENSE +21 -21
- package/README.md +446 -402
- package/__tests__/accessibility/components.test.tsx +285 -0
- package/__tests__/components/Button.test.tsx +2 -4
- package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
- package/__tests__/components/snapshots.test.tsx +131 -131
- package/__tests__/helpers/a11y.ts +54 -0
- package/__tests__/hooks/useAnalytics.test.ts +100 -0
- package/__tests__/hooks/useAnimations.test.ts +70 -0
- package/__tests__/hooks/useAuth.test.tsx +71 -28
- package/__tests__/hooks/useMedia.test.ts +318 -0
- package/__tests__/hooks/usePayments.test.tsx +307 -0
- package/__tests__/hooks/usePermission.test.ts +230 -0
- package/__tests__/hooks/useWebSocket.test.ts +329 -0
- package/__tests__/integration/auth-api.test.tsx +224 -227
- package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
- package/__tests__/services/api.test.ts +24 -6
- package/app/(auth)/home.tsx +11 -9
- package/app/(auth)/profile.tsx +8 -6
- package/app/(auth)/settings.tsx +11 -9
- package/app/(public)/forgot-password.tsx +25 -15
- package/app/(public)/login.tsx +48 -12
- package/app/(public)/onboarding.tsx +5 -5
- package/app/(public)/register.tsx +24 -15
- package/app/_layout.tsx +6 -3
- package/app.config.ts +27 -2
- package/assets/images/.gitkeep +7 -7
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/components/ErrorBoundary.tsx +73 -28
- package/components/auth/SocialLoginButtons.tsx +168 -0
- package/components/forms/FormInput.tsx +5 -3
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/AnalyticsProvider.tsx +67 -0
- package/components/providers/SuspenseBoundary.tsx +359 -357
- package/components/providers/index.ts +24 -21
- package/components/ui/AnimatedButton.tsx +1 -9
- package/components/ui/AnimatedList.tsx +98 -0
- package/components/ui/AnimatedScreen.tsx +89 -0
- package/components/ui/Avatar.tsx +319 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Button.tsx +11 -3
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/FeatureGate.tsx +57 -0
- package/components/ui/ForceUpdateScreen.tsx +108 -0
- package/components/ui/ImagePickerButton.tsx +180 -0
- package/components/ui/Input.stories.tsx +2 -10
- package/components/ui/Input.tsx +2 -10
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Paywall.tsx +253 -0
- package/components/ui/PermissionGate.tsx +155 -0
- package/components/ui/PurchaseButton.tsx +84 -0
- package/components/ui/Select.tsx +240 -240
- package/components/ui/Skeleton.tsx +3 -1
- package/components/ui/Toast.tsx +427 -418
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -30
- package/constants/config.ts +135 -97
- 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/docs/guides/analytics-posthog.md +121 -0
- package/docs/guides/auth-supabase.md +162 -0
- package/docs/guides/feature-flags-launchdarkly.md +150 -0
- package/docs/guides/payments-revenuecat.md +169 -0
- package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
- package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
- package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
- package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
- package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
- package/eas.json +2 -1
- package/hooks/index.ts +70 -40
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +5 -4
- package/hooks/useAuth.tsx +7 -3
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useChannel.ts +111 -0
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useExperiment.ts +36 -0
- package/hooks/useFeatureFlag.ts +59 -0
- package/hooks/useForceUpdate.ts +91 -0
- package/hooks/useImagePicker.ts +281 -375
- package/hooks/useInAppReview.ts +64 -0
- package/hooks/useMFA.ts +509 -499
- package/hooks/useParallax.ts +142 -0
- package/hooks/usePerformance.ts +434 -434
- package/hooks/usePermission.ts +190 -0
- package/hooks/usePresence.ts +129 -0
- package/hooks/useProducts.ts +36 -0
- package/hooks/usePurchase.ts +103 -0
- package/hooks/useRateLimit.ts +70 -0
- package/hooks/useSubscription.ts +49 -0
- package/hooks/useTrackEvent.ts +52 -0
- package/hooks/useTrackScreen.ts +40 -0
- package/hooks/useUpdates.ts +358 -358
- package/hooks/useUpload.ts +165 -0
- package/hooks/useWebSocket.ts +111 -0
- package/i18n/index.ts +197 -194
- package/i18n/locales/ar.json +170 -101
- package/i18n/locales/de.json +170 -101
- package/i18n/locales/en.json +170 -101
- package/i18n/locales/es.json +170 -101
- package/i18n/locales/fr.json +170 -101
- package/jest.config.js +1 -1
- 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 -92
- package/maestro/flows/mfa-setup.yaml +86 -86
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -101
- package/maestro/flows/offline-sync.yaml +128 -128
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +188 -176
- package/scripts/generate-placeholders.js +38 -0
- package/services/analytics/adapters/console.ts +50 -0
- package/services/analytics/analytics-adapter.ts +94 -0
- package/services/analytics/types.ts +73 -0
- package/services/analytics.ts +428 -428
- package/services/api.ts +419 -340
- package/services/auth/social/apple.ts +110 -0
- package/services/auth/social/google.ts +159 -0
- package/services/auth/social/social-auth.ts +100 -0
- package/services/auth/social/types.ts +80 -0
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +652 -626
- package/services/feature-flags/adapters/mock.ts +108 -0
- package/services/feature-flags/feature-flag-adapter.ts +174 -0
- package/services/feature-flags/types.ts +79 -0
- package/services/force-update.ts +140 -0
- package/services/index.ts +116 -54
- package/services/media/compression.ts +91 -0
- package/services/media/media-picker.ts +151 -0
- package/services/media/media-upload.ts +160 -0
- package/services/payments/adapters/mock.ts +159 -0
- package/services/payments/payment-adapter.ts +118 -0
- package/services/payments/types.ts +131 -0
- package/services/permissions/permission-manager.ts +284 -0
- package/services/permissions/types.ts +104 -0
- package/services/realtime/types.ts +100 -0
- package/services/realtime/websocket-manager.ts +441 -0
- package/services/security.ts +289 -286
- package/services/sentry.ts +4 -4
- package/stores/appStore.ts +9 -0
- package/stores/notificationStore.ts +3 -1
- package/tailwind.config.js +47 -47
- package/tsconfig.json +37 -13
- package/types/user.ts +1 -1
- package/utils/accessibility.ts +446 -446
- package/utils/animations/presets.ts +182 -0
- package/utils/animations/transitions.ts +62 -0
- package/utils/index.ts +63 -52
- package/utils/toast.ts +9 -2
- package/utils/validation.ts +4 -1
- package/utils/withAccessibility.tsx +272 -272
|
@@ -1,272 +1,272 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Accessibility enforcement HOC and utilities
|
|
3
|
-
* Provides HOCs and hooks to enforce accessibility props on components.
|
|
4
|
-
* @module utils/withAccessibility
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import React, { ComponentType, forwardRef } from "react";
|
|
8
|
-
import { AccessibilityProps, AccessibilityRole } from "react-native";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Required accessibility props that must be provided
|
|
12
|
-
*/
|
|
13
|
-
interface RequiredA11yProps {
|
|
14
|
-
accessibilityLabel: string;
|
|
15
|
-
accessibilityRole?: AccessibilityRole;
|
|
16
|
-
accessibilityHint?: string;
|
|
17
|
-
testID?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* A11y configuration for the HOC
|
|
22
|
-
*/
|
|
23
|
-
interface A11yConfig {
|
|
24
|
-
/** Default role if not provided */
|
|
25
|
-
defaultRole?: AccessibilityRole;
|
|
26
|
-
/** Whether to require accessibilityHint */
|
|
27
|
-
requireHint?: boolean;
|
|
28
|
-
/** Whether to auto-generate testID from label */
|
|
29
|
-
autoTestID?: boolean;
|
|
30
|
-
/** Prefix for auto-generated testIDs */
|
|
31
|
-
testIDPrefix?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Higher-Order Component that enforces accessibility props.
|
|
36
|
-
* Wraps a component and ensures required a11y props are provided.
|
|
37
|
-
*
|
|
38
|
-
* @param WrappedComponent - The component to wrap
|
|
39
|
-
* @param config - A11y configuration options
|
|
40
|
-
* @returns Enhanced component with a11y enforcement
|
|
41
|
-
*
|
|
42
|
-
* @example
|
|
43
|
-
* ```tsx
|
|
44
|
-
* // Create an accessible button
|
|
45
|
-
* const AccessibleButton = withAccessibility(Button, {
|
|
46
|
-
* defaultRole: 'button',
|
|
47
|
-
* autoTestID: true,
|
|
48
|
-
* testIDPrefix: 'btn',
|
|
49
|
-
* });
|
|
50
|
-
*
|
|
51
|
-
* // Usage - will error if accessibilityLabel is missing
|
|
52
|
-
* <AccessibleButton
|
|
53
|
-
* accessibilityLabel="Submit form"
|
|
54
|
-
* onPress={handleSubmit}
|
|
55
|
-
* >
|
|
56
|
-
* Submit
|
|
57
|
-
* </AccessibleButton>
|
|
58
|
-
* ```
|
|
59
|
-
*/
|
|
60
|
-
export function withAccessibility<P extends AccessibilityProps>(
|
|
61
|
-
WrappedComponent: ComponentType<P>,
|
|
62
|
-
config: A11yConfig = {}
|
|
63
|
-
) {
|
|
64
|
-
const {
|
|
65
|
-
defaultRole,
|
|
66
|
-
requireHint = false,
|
|
67
|
-
autoTestID = true,
|
|
68
|
-
testIDPrefix = "",
|
|
69
|
-
} = config;
|
|
70
|
-
|
|
71
|
-
type EnhancedProps = P & RequiredA11yProps;
|
|
72
|
-
|
|
73
|
-
const EnhancedComponent = forwardRef<unknown, EnhancedProps>((props, ref) => {
|
|
74
|
-
const {
|
|
75
|
-
accessibilityLabel,
|
|
76
|
-
accessibilityRole = defaultRole,
|
|
77
|
-
accessibilityHint,
|
|
78
|
-
testID,
|
|
79
|
-
...restProps
|
|
80
|
-
} = props;
|
|
81
|
-
|
|
82
|
-
// Development-time validation
|
|
83
|
-
if (__DEV__) {
|
|
84
|
-
if (!accessibilityLabel) {
|
|
85
|
-
console.warn(
|
|
86
|
-
`[A11y] ${WrappedComponent.displayName || "Component"} is missing accessibilityLabel`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
if (requireHint && !accessibilityHint) {
|
|
90
|
-
console.warn(
|
|
91
|
-
`[A11y] ${WrappedComponent.displayName || "Component"} is missing accessibilityHint`
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Auto-generate testID if not provided
|
|
97
|
-
const finalTestID =
|
|
98
|
-
testID ||
|
|
99
|
-
(autoTestID && accessibilityLabel
|
|
100
|
-
? `${testIDPrefix}${testIDPrefix ? "-" : ""}${accessibilityLabel
|
|
101
|
-
.toLowerCase()
|
|
102
|
-
.replace(/\s+/g, "-")
|
|
103
|
-
.replace(/[^a-z0-9-]/g, "")}`
|
|
104
|
-
: undefined);
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
<WrappedComponent
|
|
108
|
-
{...(restProps as P)}
|
|
109
|
-
ref={ref}
|
|
110
|
-
accessibilityLabel={accessibilityLabel}
|
|
111
|
-
accessibilityRole={accessibilityRole}
|
|
112
|
-
accessibilityHint={accessibilityHint}
|
|
113
|
-
testID={finalTestID}
|
|
114
|
-
accessible={true}
|
|
115
|
-
/>
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
EnhancedComponent.displayName = `WithAccessibility(${WrappedComponent.displayName || WrappedComponent.name || "Component"})`;
|
|
120
|
-
|
|
121
|
-
return EnhancedComponent;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Hook to validate accessibility props at runtime
|
|
126
|
-
*
|
|
127
|
-
* @example
|
|
128
|
-
* ```tsx
|
|
129
|
-
* function MyComponent({ accessibilityLabel, ...props }) {
|
|
130
|
-
* useAccessibilityValidation({ accessibilityLabel }, 'MyComponent');
|
|
131
|
-
* return <View {...props} />;
|
|
132
|
-
* }
|
|
133
|
-
* ```
|
|
134
|
-
*/
|
|
135
|
-
export function useAccessibilityValidation(
|
|
136
|
-
props: Partial<AccessibilityProps>,
|
|
137
|
-
componentName: string
|
|
138
|
-
): void {
|
|
139
|
-
if (__DEV__) {
|
|
140
|
-
if (!props.accessibilityLabel && !props.accessibilityLabelledBy) {
|
|
141
|
-
console.warn(
|
|
142
|
-
`[A11y] ${componentName}: Missing accessibilityLabel or accessibilityLabelledBy`
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Create accessible props from a label
|
|
150
|
-
* Utility to quickly generate standard a11y props
|
|
151
|
-
*
|
|
152
|
-
* @example
|
|
153
|
-
* ```tsx
|
|
154
|
-
* <Button {...createA11yProps('Submit form', 'button', 'Saves your changes')} />
|
|
155
|
-
* ```
|
|
156
|
-
*/
|
|
157
|
-
export function createA11yProps(
|
|
158
|
-
label: string,
|
|
159
|
-
role?: AccessibilityRole,
|
|
160
|
-
hint?: string
|
|
161
|
-
): AccessibilityProps & { testID: string } {
|
|
162
|
-
return {
|
|
163
|
-
accessible: true,
|
|
164
|
-
accessibilityLabel: label,
|
|
165
|
-
accessibilityRole: role,
|
|
166
|
-
accessibilityHint: hint,
|
|
167
|
-
testID: label
|
|
168
|
-
.toLowerCase()
|
|
169
|
-
.replace(/\s+/g, "-")
|
|
170
|
-
.replace(/[^a-z0-9-]/g, ""),
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Accessibility context for screen readers
|
|
176
|
-
*/
|
|
177
|
-
export const A11yContext = {
|
|
178
|
-
/**
|
|
179
|
-
* Check if accessibility features should be used
|
|
180
|
-
*/
|
|
181
|
-
isEnabled: (): boolean => {
|
|
182
|
-
// In a real app, check AccessibilityInfo.isScreenReaderEnabled()
|
|
183
|
-
return true;
|
|
184
|
-
},
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Standard roles for common components
|
|
188
|
-
*/
|
|
189
|
-
roles: {
|
|
190
|
-
button: "button" as AccessibilityRole,
|
|
191
|
-
link: "link" as AccessibilityRole,
|
|
192
|
-
image: "image" as AccessibilityRole,
|
|
193
|
-
text: "text" as AccessibilityRole,
|
|
194
|
-
header: "header" as AccessibilityRole,
|
|
195
|
-
search: "search" as AccessibilityRole,
|
|
196
|
-
checkbox: "checkbox" as AccessibilityRole,
|
|
197
|
-
radio: "radio" as AccessibilityRole,
|
|
198
|
-
switch: "switch" as AccessibilityRole,
|
|
199
|
-
slider: "adjustable" as AccessibilityRole,
|
|
200
|
-
tab: "tab" as AccessibilityRole,
|
|
201
|
-
tablist: "tablist" as AccessibilityRole,
|
|
202
|
-
menu: "menu" as AccessibilityRole,
|
|
203
|
-
menuitem: "menuitem" as AccessibilityRole,
|
|
204
|
-
alert: "alert" as AccessibilityRole,
|
|
205
|
-
progressbar: "progressbar" as AccessibilityRole,
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* ESLint-like runtime checker for a11y compliance
|
|
211
|
-
* Call this in development to audit component trees
|
|
212
|
-
*/
|
|
213
|
-
export function auditAccessibility(
|
|
214
|
-
element: React.ReactElement,
|
|
215
|
-
path: string = "root"
|
|
216
|
-
): string[] {
|
|
217
|
-
const warnings: string[] = [];
|
|
218
|
-
|
|
219
|
-
if (!element || typeof element !== "object") {
|
|
220
|
-
return warnings;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const props = element.props || {};
|
|
224
|
-
const type = element.type;
|
|
225
|
-
const typeName =
|
|
226
|
-
typeof type === "string"
|
|
227
|
-
? type
|
|
228
|
-
: (type as ComponentType)?.displayName ||
|
|
229
|
-
(type as ComponentType)?.name ||
|
|
230
|
-
"Unknown";
|
|
231
|
-
|
|
232
|
-
// Check for interactive elements without labels
|
|
233
|
-
const interactiveTypes = [
|
|
234
|
-
"Button",
|
|
235
|
-
"Pressable",
|
|
236
|
-
"TouchableOpacity",
|
|
237
|
-
"TouchableHighlight",
|
|
238
|
-
"TouchableWithoutFeedback",
|
|
239
|
-
"Switch",
|
|
240
|
-
"TextInput",
|
|
241
|
-
];
|
|
242
|
-
|
|
243
|
-
if (
|
|
244
|
-
interactiveTypes.some(
|
|
245
|
-
(t) => typeName.includes(t) || (typeof type === "string" && type === t)
|
|
246
|
-
)
|
|
247
|
-
) {
|
|
248
|
-
if (!props.accessibilityLabel && !props["aria-label"]) {
|
|
249
|
-
warnings.push(`${path}/${typeName}: Missing accessibility label`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Check images
|
|
254
|
-
if (typeName === "Image" || typeName === "OptimizedImage") {
|
|
255
|
-
if (!props.accessibilityLabel && !props.alt) {
|
|
256
|
-
warnings.push(`${path}/${typeName}: Image missing alt text/label`);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Recursively check children
|
|
261
|
-
if (props.children) {
|
|
262
|
-
React.Children.forEach(props.children, (child, index) => {
|
|
263
|
-
if (React.isValidElement(child)) {
|
|
264
|
-
warnings.push(
|
|
265
|
-
...auditAccessibility(child, `${path}/${typeName}[${index}]`)
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
return warnings;
|
|
272
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Accessibility enforcement HOC and utilities
|
|
3
|
+
* Provides HOCs and hooks to enforce accessibility props on components.
|
|
4
|
+
* @module utils/withAccessibility
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { ComponentType, forwardRef } from "react";
|
|
8
|
+
import { AccessibilityProps, AccessibilityRole } from "react-native";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Required accessibility props that must be provided
|
|
12
|
+
*/
|
|
13
|
+
interface RequiredA11yProps {
|
|
14
|
+
accessibilityLabel: string;
|
|
15
|
+
accessibilityRole?: AccessibilityRole;
|
|
16
|
+
accessibilityHint?: string;
|
|
17
|
+
testID?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A11y configuration for the HOC
|
|
22
|
+
*/
|
|
23
|
+
interface A11yConfig {
|
|
24
|
+
/** Default role if not provided */
|
|
25
|
+
defaultRole?: AccessibilityRole;
|
|
26
|
+
/** Whether to require accessibilityHint */
|
|
27
|
+
requireHint?: boolean;
|
|
28
|
+
/** Whether to auto-generate testID from label */
|
|
29
|
+
autoTestID?: boolean;
|
|
30
|
+
/** Prefix for auto-generated testIDs */
|
|
31
|
+
testIDPrefix?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Higher-Order Component that enforces accessibility props.
|
|
36
|
+
* Wraps a component and ensures required a11y props are provided.
|
|
37
|
+
*
|
|
38
|
+
* @param WrappedComponent - The component to wrap
|
|
39
|
+
* @param config - A11y configuration options
|
|
40
|
+
* @returns Enhanced component with a11y enforcement
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* // Create an accessible button
|
|
45
|
+
* const AccessibleButton = withAccessibility(Button, {
|
|
46
|
+
* defaultRole: 'button',
|
|
47
|
+
* autoTestID: true,
|
|
48
|
+
* testIDPrefix: 'btn',
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* // Usage - will error if accessibilityLabel is missing
|
|
52
|
+
* <AccessibleButton
|
|
53
|
+
* accessibilityLabel="Submit form"
|
|
54
|
+
* onPress={handleSubmit}
|
|
55
|
+
* >
|
|
56
|
+
* Submit
|
|
57
|
+
* </AccessibleButton>
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function withAccessibility<P extends AccessibilityProps>(
|
|
61
|
+
WrappedComponent: ComponentType<P>,
|
|
62
|
+
config: A11yConfig = {}
|
|
63
|
+
) {
|
|
64
|
+
const {
|
|
65
|
+
defaultRole,
|
|
66
|
+
requireHint = false,
|
|
67
|
+
autoTestID = true,
|
|
68
|
+
testIDPrefix = "",
|
|
69
|
+
} = config;
|
|
70
|
+
|
|
71
|
+
type EnhancedProps = P & RequiredA11yProps;
|
|
72
|
+
|
|
73
|
+
const EnhancedComponent = forwardRef<unknown, EnhancedProps>((props, ref) => {
|
|
74
|
+
const {
|
|
75
|
+
accessibilityLabel,
|
|
76
|
+
accessibilityRole = defaultRole,
|
|
77
|
+
accessibilityHint,
|
|
78
|
+
testID,
|
|
79
|
+
...restProps
|
|
80
|
+
} = props;
|
|
81
|
+
|
|
82
|
+
// Development-time validation
|
|
83
|
+
if (__DEV__) {
|
|
84
|
+
if (!accessibilityLabel) {
|
|
85
|
+
console.warn(
|
|
86
|
+
`[A11y] ${WrappedComponent.displayName || "Component"} is missing accessibilityLabel`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (requireHint && !accessibilityHint) {
|
|
90
|
+
console.warn(
|
|
91
|
+
`[A11y] ${WrappedComponent.displayName || "Component"} is missing accessibilityHint`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Auto-generate testID if not provided
|
|
97
|
+
const finalTestID =
|
|
98
|
+
testID ||
|
|
99
|
+
(autoTestID && accessibilityLabel
|
|
100
|
+
? `${testIDPrefix}${testIDPrefix ? "-" : ""}${accessibilityLabel
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/\s+/g, "-")
|
|
103
|
+
.replace(/[^a-z0-9-]/g, "")}`
|
|
104
|
+
: undefined);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<WrappedComponent
|
|
108
|
+
{...(restProps as P)}
|
|
109
|
+
ref={ref}
|
|
110
|
+
accessibilityLabel={accessibilityLabel}
|
|
111
|
+
accessibilityRole={accessibilityRole}
|
|
112
|
+
accessibilityHint={accessibilityHint}
|
|
113
|
+
testID={finalTestID}
|
|
114
|
+
accessible={true}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
EnhancedComponent.displayName = `WithAccessibility(${WrappedComponent.displayName || WrappedComponent.name || "Component"})`;
|
|
120
|
+
|
|
121
|
+
return EnhancedComponent;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Hook to validate accessibility props at runtime
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```tsx
|
|
129
|
+
* function MyComponent({ accessibilityLabel, ...props }) {
|
|
130
|
+
* useAccessibilityValidation({ accessibilityLabel }, 'MyComponent');
|
|
131
|
+
* return <View {...props} />;
|
|
132
|
+
* }
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export function useAccessibilityValidation(
|
|
136
|
+
props: Partial<AccessibilityProps>,
|
|
137
|
+
componentName: string
|
|
138
|
+
): void {
|
|
139
|
+
if (__DEV__) {
|
|
140
|
+
if (!props.accessibilityLabel && !props.accessibilityLabelledBy) {
|
|
141
|
+
console.warn(
|
|
142
|
+
`[A11y] ${componentName}: Missing accessibilityLabel or accessibilityLabelledBy`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create accessible props from a label
|
|
150
|
+
* Utility to quickly generate standard a11y props
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```tsx
|
|
154
|
+
* <Button {...createA11yProps('Submit form', 'button', 'Saves your changes')} />
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export function createA11yProps(
|
|
158
|
+
label: string,
|
|
159
|
+
role?: AccessibilityRole,
|
|
160
|
+
hint?: string
|
|
161
|
+
): AccessibilityProps & { testID: string } {
|
|
162
|
+
return {
|
|
163
|
+
accessible: true,
|
|
164
|
+
accessibilityLabel: label,
|
|
165
|
+
accessibilityRole: role,
|
|
166
|
+
accessibilityHint: hint,
|
|
167
|
+
testID: label
|
|
168
|
+
.toLowerCase()
|
|
169
|
+
.replace(/\s+/g, "-")
|
|
170
|
+
.replace(/[^a-z0-9-]/g, ""),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Accessibility context for screen readers
|
|
176
|
+
*/
|
|
177
|
+
export const A11yContext = {
|
|
178
|
+
/**
|
|
179
|
+
* Check if accessibility features should be used
|
|
180
|
+
*/
|
|
181
|
+
isEnabled: (): boolean => {
|
|
182
|
+
// In a real app, check AccessibilityInfo.isScreenReaderEnabled()
|
|
183
|
+
return true;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Standard roles for common components
|
|
188
|
+
*/
|
|
189
|
+
roles: {
|
|
190
|
+
button: "button" as AccessibilityRole,
|
|
191
|
+
link: "link" as AccessibilityRole,
|
|
192
|
+
image: "image" as AccessibilityRole,
|
|
193
|
+
text: "text" as AccessibilityRole,
|
|
194
|
+
header: "header" as AccessibilityRole,
|
|
195
|
+
search: "search" as AccessibilityRole,
|
|
196
|
+
checkbox: "checkbox" as AccessibilityRole,
|
|
197
|
+
radio: "radio" as AccessibilityRole,
|
|
198
|
+
switch: "switch" as AccessibilityRole,
|
|
199
|
+
slider: "adjustable" as AccessibilityRole,
|
|
200
|
+
tab: "tab" as AccessibilityRole,
|
|
201
|
+
tablist: "tablist" as AccessibilityRole,
|
|
202
|
+
menu: "menu" as AccessibilityRole,
|
|
203
|
+
menuitem: "menuitem" as AccessibilityRole,
|
|
204
|
+
alert: "alert" as AccessibilityRole,
|
|
205
|
+
progressbar: "progressbar" as AccessibilityRole,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* ESLint-like runtime checker for a11y compliance
|
|
211
|
+
* Call this in development to audit component trees
|
|
212
|
+
*/
|
|
213
|
+
export function auditAccessibility(
|
|
214
|
+
element: React.ReactElement,
|
|
215
|
+
path: string = "root"
|
|
216
|
+
): string[] {
|
|
217
|
+
const warnings: string[] = [];
|
|
218
|
+
|
|
219
|
+
if (!element || typeof element !== "object") {
|
|
220
|
+
return warnings;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const props = element.props || {};
|
|
224
|
+
const type = element.type;
|
|
225
|
+
const typeName =
|
|
226
|
+
typeof type === "string"
|
|
227
|
+
? type
|
|
228
|
+
: (type as ComponentType)?.displayName ||
|
|
229
|
+
(type as ComponentType)?.name ||
|
|
230
|
+
"Unknown";
|
|
231
|
+
|
|
232
|
+
// Check for interactive elements without labels
|
|
233
|
+
const interactiveTypes = [
|
|
234
|
+
"Button",
|
|
235
|
+
"Pressable",
|
|
236
|
+
"TouchableOpacity",
|
|
237
|
+
"TouchableHighlight",
|
|
238
|
+
"TouchableWithoutFeedback",
|
|
239
|
+
"Switch",
|
|
240
|
+
"TextInput",
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
if (
|
|
244
|
+
interactiveTypes.some(
|
|
245
|
+
(t) => typeName.includes(t) || (typeof type === "string" && type === t)
|
|
246
|
+
)
|
|
247
|
+
) {
|
|
248
|
+
if (!props.accessibilityLabel && !props["aria-label"]) {
|
|
249
|
+
warnings.push(`${path}/${typeName}: Missing accessibility label`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check images
|
|
254
|
+
if (typeName === "Image" || typeName === "OptimizedImage") {
|
|
255
|
+
if (!props.accessibilityLabel && !props.alt) {
|
|
256
|
+
warnings.push(`${path}/${typeName}: Image missing alt text/label`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Recursively check children
|
|
261
|
+
if (props.children) {
|
|
262
|
+
React.Children.forEach(props.children, (child, index) => {
|
|
263
|
+
if (React.isValidElement(child)) {
|
|
264
|
+
warnings.push(
|
|
265
|
+
...auditAccessibility(child, `${path}/${typeName}[${index}]`)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return warnings;
|
|
272
|
+
}
|