@qwickapps/react-framework 1.3.2 → 1.3.3
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/README.md +106 -0
- package/dist/components/AccessibilityProvider.d.ts +64 -0
- package/dist/components/AccessibilityProvider.d.ts.map +1 -0
- package/dist/components/Breadcrumbs.d.ts +39 -0
- package/dist/components/Breadcrumbs.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +39 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/QwickApp.d.ts.map +1 -1
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.bundled.css +12 -0
- package/dist/index.esm.js +795 -12
- package/dist/index.js +800 -10
- package/package.json +1 -1
- package/src/components/AccessibilityProvider.tsx +466 -0
- package/src/components/Breadcrumbs.tsx +223 -0
- package/src/components/ErrorBoundary.tsx +216 -0
- package/src/components/QwickApp.tsx +17 -11
- package/src/components/__tests__/AccessibilityProvider.test.tsx +330 -0
- package/src/components/__tests__/Breadcrumbs.test.tsx +268 -0
- package/src/components/__tests__/ErrorBoundary.test.tsx +163 -0
- package/src/components/index.ts +3 -0
- package/src/stories/AccessibilityProvider.stories.tsx +284 -0
- package/src/stories/Breadcrumbs.stories.tsx +304 -0
- package/src/stories/ErrorBoundary.stories.tsx +159 -0
- package/dist/schemas/Builders.d.ts +0 -7
- package/dist/schemas/Builders.d.ts.map +0 -1
- package/dist/schemas/types.d.ts +0 -7
- package/dist/schemas/types.d.ts.map +0 -1
- package/dist/types/DataBinding.d.ts +0 -7
- package/dist/types/DataBinding.d.ts.map +0 -1
- package/dist/types/DataProvider.d.ts +0 -7
- package/dist/types/DataProvider.d.ts.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qwickapps/react-framework",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Complete React framework with responsive navigation, flexible layouts, theming system, and reusable components for building modern applications.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useReducer, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
// Accessibility State
|
|
4
|
+
export interface AccessibilityState {
|
|
5
|
+
highContrast: boolean;
|
|
6
|
+
reducedMotion: boolean;
|
|
7
|
+
largeText: boolean;
|
|
8
|
+
focusVisible: boolean;
|
|
9
|
+
isKeyboardUser: boolean;
|
|
10
|
+
issues: AccessibilityIssue[];
|
|
11
|
+
lastAnnouncement: Announcement | null;
|
|
12
|
+
preferences: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AccessibilityIssue {
|
|
16
|
+
type: string;
|
|
17
|
+
message: string;
|
|
18
|
+
level: 'error' | 'warning' | 'info';
|
|
19
|
+
element?: Element;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Announcement {
|
|
23
|
+
message: string;
|
|
24
|
+
level: 'polite' | 'assertive';
|
|
25
|
+
timestamp: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Actions
|
|
29
|
+
type AccessibilityAction =
|
|
30
|
+
| { type: 'SET_HIGH_CONTRAST'; payload: boolean }
|
|
31
|
+
| { type: 'SET_REDUCED_MOTION'; payload: boolean }
|
|
32
|
+
| { type: 'SET_LARGE_TEXT'; payload: boolean }
|
|
33
|
+
| { type: 'SET_FOCUS_VISIBLE'; payload: boolean }
|
|
34
|
+
| { type: 'SET_KEYBOARD_USER'; payload: boolean }
|
|
35
|
+
| { type: 'ADD_ISSUE'; payload: AccessibilityIssue }
|
|
36
|
+
| { type: 'CLEAR_ISSUES' }
|
|
37
|
+
| { type: 'SET_ANNOUNCEMENT'; payload: Announcement };
|
|
38
|
+
|
|
39
|
+
// Context
|
|
40
|
+
export interface AccessibilityContextValue extends AccessibilityState {
|
|
41
|
+
setHighContrast: (enabled: boolean) => void;
|
|
42
|
+
setReducedMotion: (enabled: boolean) => void;
|
|
43
|
+
setLargeText: (enabled: boolean) => void;
|
|
44
|
+
setFocusVisible: (enabled: boolean) => void;
|
|
45
|
+
announce: (message: string, level?: 'polite' | 'assertive') => void;
|
|
46
|
+
announcePolite: (message: string) => void;
|
|
47
|
+
announceAssertive: (message: string) => void;
|
|
48
|
+
addIssue: (issue: AccessibilityIssue) => void;
|
|
49
|
+
clearIssues: () => void;
|
|
50
|
+
runAudit: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const AccessibilityContext = createContext<AccessibilityContextValue | null>(null);
|
|
54
|
+
|
|
55
|
+
// Reducer
|
|
56
|
+
const accessibilityReducer = (state: AccessibilityState, action: AccessibilityAction): AccessibilityState => {
|
|
57
|
+
switch (action.type) {
|
|
58
|
+
case 'SET_HIGH_CONTRAST':
|
|
59
|
+
return { ...state, highContrast: action.payload };
|
|
60
|
+
case 'SET_REDUCED_MOTION':
|
|
61
|
+
return { ...state, reducedMotion: action.payload };
|
|
62
|
+
case 'SET_LARGE_TEXT':
|
|
63
|
+
return { ...state, largeText: action.payload };
|
|
64
|
+
case 'SET_FOCUS_VISIBLE':
|
|
65
|
+
return { ...state, focusVisible: action.payload };
|
|
66
|
+
case 'SET_KEYBOARD_USER':
|
|
67
|
+
return { ...state, isKeyboardUser: action.payload };
|
|
68
|
+
case 'ADD_ISSUE':
|
|
69
|
+
return { ...state, issues: [...state.issues, action.payload] };
|
|
70
|
+
case 'CLEAR_ISSUES':
|
|
71
|
+
return { ...state, issues: [] };
|
|
72
|
+
case 'SET_ANNOUNCEMENT':
|
|
73
|
+
return { ...state, lastAnnouncement: action.payload };
|
|
74
|
+
default:
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Initial state
|
|
80
|
+
const initialState: AccessibilityState = {
|
|
81
|
+
highContrast: false,
|
|
82
|
+
reducedMotion: false,
|
|
83
|
+
largeText: false,
|
|
84
|
+
focusVisible: true,
|
|
85
|
+
isKeyboardUser: false,
|
|
86
|
+
issues: [],
|
|
87
|
+
lastAnnouncement: null,
|
|
88
|
+
preferences: {}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// ARIA Live Manager
|
|
92
|
+
class AriaLiveManager {
|
|
93
|
+
private politeRegion: HTMLElement | null = null;
|
|
94
|
+
private assertiveRegion: HTMLElement | null = null;
|
|
95
|
+
|
|
96
|
+
constructor() {
|
|
97
|
+
this.createLiveRegions();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private createLiveRegions() {
|
|
101
|
+
if (typeof document === 'undefined') return;
|
|
102
|
+
|
|
103
|
+
// Polite announcements
|
|
104
|
+
this.politeRegion = document.createElement('div');
|
|
105
|
+
this.politeRegion.setAttribute('aria-live', 'polite');
|
|
106
|
+
this.politeRegion.setAttribute('aria-atomic', 'true');
|
|
107
|
+
this.politeRegion.setAttribute('id', 'qwickapps-aria-live-polite');
|
|
108
|
+
this.politeRegion.style.cssText = `
|
|
109
|
+
position: absolute !important;
|
|
110
|
+
left: -10000px !important;
|
|
111
|
+
width: 1px !important;
|
|
112
|
+
height: 1px !important;
|
|
113
|
+
overflow: hidden !important;
|
|
114
|
+
`;
|
|
115
|
+
document.body.appendChild(this.politeRegion);
|
|
116
|
+
|
|
117
|
+
// Assertive announcements
|
|
118
|
+
this.assertiveRegion = document.createElement('div');
|
|
119
|
+
this.assertiveRegion.setAttribute('aria-live', 'assertive');
|
|
120
|
+
this.assertiveRegion.setAttribute('aria-atomic', 'true');
|
|
121
|
+
this.assertiveRegion.setAttribute('id', 'qwickapps-aria-live-assertive');
|
|
122
|
+
this.assertiveRegion.style.cssText = `
|
|
123
|
+
position: absolute !important;
|
|
124
|
+
left: -10000px !important;
|
|
125
|
+
width: 1px !important;
|
|
126
|
+
height: 1px !important;
|
|
127
|
+
overflow: hidden !important;
|
|
128
|
+
`;
|
|
129
|
+
document.body.appendChild(this.assertiveRegion);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
announce(message: string, level: 'polite' | 'assertive' = 'polite') {
|
|
133
|
+
if (level === 'assertive') {
|
|
134
|
+
this.announceAssertive(message);
|
|
135
|
+
} else {
|
|
136
|
+
this.announcePolite(message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
announcePolite(message: string) {
|
|
141
|
+
if (!this.politeRegion) return;
|
|
142
|
+
|
|
143
|
+
this.politeRegion.textContent = '';
|
|
144
|
+
// Small delay ensures screen readers detect the change
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
if (this.politeRegion) {
|
|
147
|
+
this.politeRegion.textContent = message;
|
|
148
|
+
}
|
|
149
|
+
}, 100);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
announceAssertive(message: string) {
|
|
153
|
+
if (!this.assertiveRegion) return;
|
|
154
|
+
|
|
155
|
+
this.assertiveRegion.textContent = '';
|
|
156
|
+
// Small delay ensures screen readers detect the change
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
if (this.assertiveRegion) {
|
|
159
|
+
this.assertiveRegion.textContent = message;
|
|
160
|
+
}
|
|
161
|
+
}, 100);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ariaLiveManager = new AriaLiveManager();
|
|
166
|
+
|
|
167
|
+
// Props
|
|
168
|
+
export interface AccessibilityProviderProps {
|
|
169
|
+
children: ReactNode;
|
|
170
|
+
enableAudit?: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Accessibility Provider Component
|
|
175
|
+
* Provides comprehensive accessibility context and utilities
|
|
176
|
+
*
|
|
177
|
+
* Features:
|
|
178
|
+
* - System preference detection (high contrast, reduced motion)
|
|
179
|
+
* - Keyboard navigation detection
|
|
180
|
+
* - ARIA live announcements
|
|
181
|
+
* - Focus management
|
|
182
|
+
* - Accessibility auditing
|
|
183
|
+
* - Settings persistence
|
|
184
|
+
*/
|
|
185
|
+
export const AccessibilityProvider: React.FC<AccessibilityProviderProps> = ({
|
|
186
|
+
children,
|
|
187
|
+
enableAudit = process.env.NODE_ENV === 'development'
|
|
188
|
+
}) => {
|
|
189
|
+
const [state, dispatch] = useReducer(accessibilityReducer, initialState);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
// Detect user preferences from system
|
|
193
|
+
detectUserPreferences();
|
|
194
|
+
|
|
195
|
+
// Set up keyboard detection
|
|
196
|
+
const keyboardCleanup = setupKeyboardDetection();
|
|
197
|
+
|
|
198
|
+
// Initialize focus management
|
|
199
|
+
initializeFocusManagement();
|
|
200
|
+
|
|
201
|
+
// Run initial accessibility audit
|
|
202
|
+
if (enableAudit) {
|
|
203
|
+
runAccessibilityAudit();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Cleanup
|
|
207
|
+
return () => {
|
|
208
|
+
if (keyboardCleanup) keyboardCleanup();
|
|
209
|
+
};
|
|
210
|
+
}, [enableAudit]);
|
|
211
|
+
|
|
212
|
+
const detectUserPreferences = () => {
|
|
213
|
+
if (typeof window === 'undefined') return;
|
|
214
|
+
|
|
215
|
+
// High contrast mode
|
|
216
|
+
if (window.matchMedia && window.matchMedia('(prefers-contrast: high)').matches) {
|
|
217
|
+
dispatch({ type: 'SET_HIGH_CONTRAST', payload: true });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Reduced motion
|
|
221
|
+
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
222
|
+
dispatch({ type: 'SET_REDUCED_MOTION', payload: true });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Listen for changes
|
|
226
|
+
if (window.matchMedia) {
|
|
227
|
+
const contrastMedia = window.matchMedia('(prefers-contrast: high)');
|
|
228
|
+
const motionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
229
|
+
|
|
230
|
+
const contrastHandler = (e: MediaQueryListEvent) => {
|
|
231
|
+
dispatch({ type: 'SET_HIGH_CONTRAST', payload: e.matches });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const motionHandler = (e: MediaQueryListEvent) => {
|
|
235
|
+
dispatch({ type: 'SET_REDUCED_MOTION', payload: e.matches });
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
contrastMedia.addEventListener('change', contrastHandler);
|
|
239
|
+
motionMedia.addEventListener('change', motionHandler);
|
|
240
|
+
|
|
241
|
+
// Return cleanup function
|
|
242
|
+
return () => {
|
|
243
|
+
contrastMedia.removeEventListener('change', contrastHandler);
|
|
244
|
+
motionMedia.removeEventListener('change', motionHandler);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const setupKeyboardDetection = () => {
|
|
250
|
+
if (typeof document === 'undefined') return;
|
|
251
|
+
|
|
252
|
+
let keyboardUser = false;
|
|
253
|
+
|
|
254
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
255
|
+
if (e.key === 'Tab') {
|
|
256
|
+
keyboardUser = true;
|
|
257
|
+
dispatch({ type: 'SET_KEYBOARD_USER', payload: true });
|
|
258
|
+
document.body.classList.add('keyboard-user');
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const handleMouseDown = () => {
|
|
263
|
+
if (keyboardUser) {
|
|
264
|
+
keyboardUser = false;
|
|
265
|
+
dispatch({ type: 'SET_KEYBOARD_USER', payload: false });
|
|
266
|
+
document.body.classList.remove('keyboard-user');
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
271
|
+
document.addEventListener('mousedown', handleMouseDown);
|
|
272
|
+
|
|
273
|
+
return () => {
|
|
274
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
275
|
+
document.removeEventListener('mousedown', handleMouseDown);
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const initializeFocusManagement = () => {
|
|
280
|
+
if (typeof document === 'undefined') return;
|
|
281
|
+
|
|
282
|
+
// Enhanced focus indicators for keyboard users
|
|
283
|
+
const style = document.createElement('style');
|
|
284
|
+
style.textContent = `
|
|
285
|
+
.keyboard-user *:focus {
|
|
286
|
+
outline: 3px solid #005cee !important;
|
|
287
|
+
outline-offset: 2px !important;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.high-contrast *:focus {
|
|
291
|
+
outline: 3px solid #ffffff !important;
|
|
292
|
+
outline-offset: 2px !important;
|
|
293
|
+
box-shadow: 0 0 0 1px #000000 !important;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.reduced-motion * {
|
|
297
|
+
animation-duration: 0.01ms !important;
|
|
298
|
+
animation-iteration-count: 1 !important;
|
|
299
|
+
transition-duration: 0.01ms !important;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.large-text {
|
|
303
|
+
font-size: 1.2em !important;
|
|
304
|
+
}
|
|
305
|
+
`;
|
|
306
|
+
document.head.appendChild(style);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const runAccessibilityAudit = () => {
|
|
310
|
+
if (typeof document === 'undefined') return;
|
|
311
|
+
|
|
312
|
+
setTimeout(() => {
|
|
313
|
+
const issues: AccessibilityIssue[] = [];
|
|
314
|
+
|
|
315
|
+
// Check for images without alt text
|
|
316
|
+
const images = document.querySelectorAll('img:not([alt])');
|
|
317
|
+
images.forEach(img => {
|
|
318
|
+
issues.push({
|
|
319
|
+
type: 'missing-alt-text',
|
|
320
|
+
message: 'Image missing alt attribute',
|
|
321
|
+
level: 'error',
|
|
322
|
+
element: img
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Check for buttons without accessible names
|
|
327
|
+
const buttons = document.querySelectorAll('button:not([aria-label]):not([title])');
|
|
328
|
+
buttons.forEach(button => {
|
|
329
|
+
if (!button.textContent?.trim()) {
|
|
330
|
+
issues.push({
|
|
331
|
+
type: 'unnamed-button',
|
|
332
|
+
message: 'Button missing accessible name',
|
|
333
|
+
level: 'error',
|
|
334
|
+
element: button
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Check for form inputs without labels
|
|
340
|
+
const inputs = document.querySelectorAll('input:not([aria-label]):not([title])');
|
|
341
|
+
inputs.forEach(input => {
|
|
342
|
+
const id = input.getAttribute('id');
|
|
343
|
+
if (id) {
|
|
344
|
+
const label = document.querySelector(`label[for="${id}"]`);
|
|
345
|
+
if (!label) {
|
|
346
|
+
issues.push({
|
|
347
|
+
type: 'unlabeled-input',
|
|
348
|
+
message: 'Form input missing label',
|
|
349
|
+
level: 'error',
|
|
350
|
+
element: input
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
issues.push({
|
|
355
|
+
type: 'unlabeled-input',
|
|
356
|
+
message: 'Form input missing label',
|
|
357
|
+
level: 'error',
|
|
358
|
+
element: input
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
dispatch({ type: 'CLEAR_ISSUES' });
|
|
364
|
+
|
|
365
|
+
issues.forEach(issue => {
|
|
366
|
+
dispatch({ type: 'ADD_ISSUE', payload: issue });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (issues.length > 0) {
|
|
370
|
+
console.group('🔍 Accessibility Issues Found');
|
|
371
|
+
issues.forEach(issue => {
|
|
372
|
+
const logMethod = issue.level === 'error' ? console.error : console.warn;
|
|
373
|
+
logMethod(`${issue.type}: ${issue.message}`);
|
|
374
|
+
});
|
|
375
|
+
console.groupEnd();
|
|
376
|
+
}
|
|
377
|
+
}, 1000);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Context value
|
|
381
|
+
const contextValue: AccessibilityContextValue = {
|
|
382
|
+
...state,
|
|
383
|
+
|
|
384
|
+
// Actions
|
|
385
|
+
setHighContrast: (enabled: boolean) => dispatch({ type: 'SET_HIGH_CONTRAST', payload: enabled }),
|
|
386
|
+
setReducedMotion: (enabled: boolean) => dispatch({ type: 'SET_REDUCED_MOTION', payload: enabled }),
|
|
387
|
+
setLargeText: (enabled: boolean) => dispatch({ type: 'SET_LARGE_TEXT', payload: enabled }),
|
|
388
|
+
setFocusVisible: (enabled: boolean) => dispatch({ type: 'SET_FOCUS_VISIBLE', payload: enabled }),
|
|
389
|
+
|
|
390
|
+
// Utilities
|
|
391
|
+
announce: (message: string, level: 'polite' | 'assertive' = 'polite') => {
|
|
392
|
+
ariaLiveManager.announce(message, level);
|
|
393
|
+
dispatch({ type: 'SET_ANNOUNCEMENT', payload: { message, level, timestamp: Date.now() } });
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
announcePolite: (message: string) => {
|
|
397
|
+
ariaLiveManager.announcePolite(message);
|
|
398
|
+
dispatch({ type: 'SET_ANNOUNCEMENT', payload: { message, level: 'polite', timestamp: Date.now() } });
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
announceAssertive: (message: string) => {
|
|
402
|
+
ariaLiveManager.announceAssertive(message);
|
|
403
|
+
dispatch({ type: 'SET_ANNOUNCEMENT', payload: { message, level: 'assertive', timestamp: Date.now() } });
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
addIssue: (issue: AccessibilityIssue) => dispatch({ type: 'ADD_ISSUE', payload: issue }),
|
|
407
|
+
clearIssues: () => dispatch({ type: 'CLEAR_ISSUES' }),
|
|
408
|
+
|
|
409
|
+
// Audit function
|
|
410
|
+
runAudit: runAccessibilityAudit
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// Apply CSS classes based on preferences
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
if (typeof document === 'undefined') return;
|
|
416
|
+
|
|
417
|
+
const { highContrast, reducedMotion, largeText } = state;
|
|
418
|
+
|
|
419
|
+
document.body.classList.toggle('high-contrast', highContrast);
|
|
420
|
+
document.body.classList.toggle('reduced-motion', reducedMotion);
|
|
421
|
+
document.body.classList.toggle('large-text', largeText);
|
|
422
|
+
}, [state.highContrast, state.reducedMotion, state.largeText]);
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<AccessibilityContext.Provider value={contextValue}>
|
|
426
|
+
{children}
|
|
427
|
+
</AccessibilityContext.Provider>
|
|
428
|
+
);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Hook to access accessibility context
|
|
433
|
+
*/
|
|
434
|
+
export const useAccessibility = (): AccessibilityContextValue => {
|
|
435
|
+
const context = useContext(AccessibilityContext);
|
|
436
|
+
|
|
437
|
+
if (!context) {
|
|
438
|
+
throw new Error('useAccessibility must be used within an AccessibilityProvider');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return context;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Higher-Order Component for accessibility enhancements
|
|
446
|
+
*/
|
|
447
|
+
export const withAccessibility = <P extends object>(
|
|
448
|
+
WrappedComponent: React.ComponentType<P>
|
|
449
|
+
) => {
|
|
450
|
+
const AccessibilityEnhancedComponent = (props: P) => {
|
|
451
|
+
const accessibility = useAccessibility();
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<WrappedComponent
|
|
455
|
+
{...props}
|
|
456
|
+
accessibility={accessibility}
|
|
457
|
+
/>
|
|
458
|
+
);
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
AccessibilityEnhancedComponent.displayName = `withAccessibility(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
|
|
462
|
+
|
|
463
|
+
return AccessibilityEnhancedComponent;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
export default AccessibilityProvider;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface BreadcrumbItem {
|
|
4
|
+
label: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
icon?: React.ReactNode;
|
|
7
|
+
current?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BreadcrumbsProps {
|
|
11
|
+
items: BreadcrumbItem[];
|
|
12
|
+
separator?: React.ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
onNavigate?: (item: BreadcrumbItem, index: number) => void;
|
|
15
|
+
maxItems?: number;
|
|
16
|
+
showRoot?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generic Breadcrumbs component for navigation hierarchy
|
|
21
|
+
*
|
|
22
|
+
* Features:
|
|
23
|
+
* - Accessible navigation with proper ARIA labels
|
|
24
|
+
* - Customizable separators and icons
|
|
25
|
+
* - Responsive design with item truncation
|
|
26
|
+
* - Support for custom navigation handlers
|
|
27
|
+
* - Keyboard navigation support
|
|
28
|
+
* - Screen reader friendly
|
|
29
|
+
*/
|
|
30
|
+
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
|
|
31
|
+
items,
|
|
32
|
+
separator = '/',
|
|
33
|
+
className = '',
|
|
34
|
+
onNavigate,
|
|
35
|
+
maxItems,
|
|
36
|
+
showRoot = true
|
|
37
|
+
}) => {
|
|
38
|
+
// Filter and prepare items
|
|
39
|
+
let displayItems = showRoot ? items : items.slice(1);
|
|
40
|
+
|
|
41
|
+
// Handle max items with ellipsis
|
|
42
|
+
if (maxItems && displayItems.length > maxItems) {
|
|
43
|
+
const firstItems = displayItems.slice(0, 1);
|
|
44
|
+
const lastItems = displayItems.slice(-Math.max(1, maxItems - 2));
|
|
45
|
+
displayItems = [
|
|
46
|
+
...firstItems,
|
|
47
|
+
{ label: '...', href: undefined, current: false },
|
|
48
|
+
...lastItems
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handleItemClick = (e: React.MouseEvent, item: BreadcrumbItem, index: number) => {
|
|
53
|
+
if (onNavigate) {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
onNavigate(item, index);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleKeyDown = (e: React.KeyboardEvent, item: BreadcrumbItem, index: number) => {
|
|
60
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
if (onNavigate) {
|
|
63
|
+
onNavigate(item, index);
|
|
64
|
+
} else if (item.href) {
|
|
65
|
+
window.location.href = item.href;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (displayItems.length <= 1) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<nav
|
|
76
|
+
className={`breadcrumbs ${className}`}
|
|
77
|
+
role="navigation"
|
|
78
|
+
aria-label="Breadcrumb navigation"
|
|
79
|
+
style={{
|
|
80
|
+
display: 'flex',
|
|
81
|
+
alignItems: 'center',
|
|
82
|
+
fontSize: '14px',
|
|
83
|
+
color: '#6b7280',
|
|
84
|
+
...defaultStyles.nav
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<ol
|
|
88
|
+
style={{
|
|
89
|
+
display: 'flex',
|
|
90
|
+
alignItems: 'center',
|
|
91
|
+
listStyle: 'none',
|
|
92
|
+
margin: 0,
|
|
93
|
+
padding: 0,
|
|
94
|
+
gap: '8px'
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{displayItems.map((item, index) => {
|
|
98
|
+
const isLast = index === displayItems.length - 1;
|
|
99
|
+
const isClickable = (item.href || onNavigate) && !item.current && item.label !== '...';
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<li key={`${item.label}-${index}`} style={{ display: 'flex', alignItems: 'center' }}>
|
|
103
|
+
{isClickable ? (
|
|
104
|
+
<a
|
|
105
|
+
href={item.href}
|
|
106
|
+
onClick={(e) => handleItemClick(e, item, index)}
|
|
107
|
+
onKeyDown={(e) => handleKeyDown(e, item, index)}
|
|
108
|
+
style={{
|
|
109
|
+
...defaultStyles.link,
|
|
110
|
+
color: isLast ? '#374151' : '#6b7280',
|
|
111
|
+
textDecoration: 'none',
|
|
112
|
+
display: 'flex',
|
|
113
|
+
alignItems: 'center',
|
|
114
|
+
gap: '4px'
|
|
115
|
+
}}
|
|
116
|
+
tabIndex={0}
|
|
117
|
+
aria-current={item.current ? 'page' : undefined}
|
|
118
|
+
>
|
|
119
|
+
{item.icon && (
|
|
120
|
+
<span
|
|
121
|
+
style={{ display: 'flex', alignItems: 'center' }}
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
>
|
|
124
|
+
{item.icon}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
<span>{item.label}</span>
|
|
128
|
+
</a>
|
|
129
|
+
) : (
|
|
130
|
+
<span
|
|
131
|
+
style={{
|
|
132
|
+
...defaultStyles.current,
|
|
133
|
+
color: isLast ? '#111827' : '#6b7280',
|
|
134
|
+
fontWeight: isLast ? 600 : 400,
|
|
135
|
+
display: 'flex',
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
gap: '4px'
|
|
138
|
+
}}
|
|
139
|
+
aria-current={item.current ? 'page' : undefined}
|
|
140
|
+
>
|
|
141
|
+
{item.icon && (
|
|
142
|
+
<span
|
|
143
|
+
style={{ display: 'flex', alignItems: 'center' }}
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
>
|
|
146
|
+
{item.icon}
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
<span>{item.label}</span>
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{!isLast && (
|
|
154
|
+
<span
|
|
155
|
+
style={{
|
|
156
|
+
display: 'flex',
|
|
157
|
+
alignItems: 'center',
|
|
158
|
+
marginLeft: '8px',
|
|
159
|
+
color: '#d1d5db',
|
|
160
|
+
fontSize: '12px'
|
|
161
|
+
}}
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
>
|
|
164
|
+
{separator}
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
</li>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</ol>
|
|
171
|
+
</nav>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Default styles
|
|
176
|
+
const defaultStyles = {
|
|
177
|
+
nav: {
|
|
178
|
+
padding: '8px 0'
|
|
179
|
+
},
|
|
180
|
+
link: {
|
|
181
|
+
transition: 'color 0.2s ease',
|
|
182
|
+
cursor: 'pointer',
|
|
183
|
+
borderRadius: '4px',
|
|
184
|
+
padding: '4px',
|
|
185
|
+
margin: '-4px'
|
|
186
|
+
},
|
|
187
|
+
current: {
|
|
188
|
+
padding: '4px'
|
|
189
|
+
}
|
|
190
|
+
} as const;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Hook for managing breadcrumb state
|
|
194
|
+
*/
|
|
195
|
+
export const useBreadcrumbs = () => {
|
|
196
|
+
const [breadcrumbs, setBreadcrumbs] = React.useState<BreadcrumbItem[]>([]);
|
|
197
|
+
|
|
198
|
+
const addBreadcrumb = React.useCallback((item: BreadcrumbItem) => {
|
|
199
|
+
setBreadcrumbs(prev => [...prev, item]);
|
|
200
|
+
}, []);
|
|
201
|
+
|
|
202
|
+
const removeBreadcrumb = React.useCallback((index: number) => {
|
|
203
|
+
setBreadcrumbs(prev => prev.filter((_, i) => i !== index));
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
const setBreadcrumbsCurrent = React.useCallback((items: BreadcrumbItem[]) => {
|
|
207
|
+
setBreadcrumbs(items);
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
const clearBreadcrumbs = React.useCallback(() => {
|
|
211
|
+
setBreadcrumbs([]);
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
breadcrumbs,
|
|
216
|
+
addBreadcrumb,
|
|
217
|
+
removeBreadcrumb,
|
|
218
|
+
setBreadcrumbs: setBreadcrumbsCurrent,
|
|
219
|
+
clearBreadcrumbs
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export default Breadcrumbs;
|