@fragments-sdk/ui 0.2.3 → 0.4.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/fragments.json +1 -1
- package/package.json +9 -4
- package/src/components/Accordion/Accordion.fragment.tsx +186 -0
- package/src/components/Accordion/Accordion.module.scss +111 -0
- package/src/components/Accordion/index.tsx +271 -0
- package/src/components/Alert/Alert.fragment.tsx +66 -41
- package/src/components/Alert/Alert.module.scss +31 -21
- package/src/components/Alert/index.tsx +202 -73
- package/src/components/AppShell/AppShell.fragment.tsx +315 -0
- package/src/components/AppShell/AppShell.module.scss +213 -0
- package/src/components/AppShell/index.tsx +398 -0
- package/src/components/Avatar/index.tsx +8 -9
- package/src/components/Badge/Badge.module.scss +16 -10
- package/src/components/Badge/index.tsx +20 -6
- package/src/components/Box/Box.fragment.tsx +168 -0
- package/src/components/Box/Box.module.scss +84 -0
- package/src/components/Box/index.tsx +78 -0
- package/src/components/Button/Button.module.scss +42 -0
- package/src/components/Button/index.tsx +67 -33
- package/src/components/ButtonGroup/ButtonGroup.module.scss +37 -0
- package/src/components/ButtonGroup/index.tsx +40 -0
- package/src/components/Card/Card.fragment.tsx +51 -25
- package/src/components/Card/Card.module.scss +52 -5
- package/src/components/Card/index.tsx +154 -53
- package/src/components/Checkbox/Checkbox.module.scss +4 -4
- package/src/components/Checkbox/index.tsx +3 -4
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +201 -0
- package/src/components/CodeBlock/CodeBlock.module.scss +224 -0
- package/src/components/CodeBlock/index.tsx +385 -0
- package/src/components/ColorChip/ColorChip.module.scss +165 -0
- package/src/components/ColorChip/index.tsx +157 -0
- package/src/components/ColorPicker/ColorPicker.module.scss +109 -0
- package/src/components/ColorPicker/index.tsx +107 -0
- package/src/components/Dialog/Dialog.fragment.tsx +9 -0
- package/src/components/Dialog/Dialog.module.scss +26 -7
- package/src/components/Dialog/index.tsx +12 -15
- package/src/components/EmptyState/EmptyState.fragment.tsx +54 -71
- package/src/components/EmptyState/EmptyState.module.scss +9 -9
- package/src/components/EmptyState/index.tsx +104 -69
- package/src/components/Field/Field.fragment.tsx +165 -0
- package/src/components/Field/Field.module.scss +31 -0
- package/src/components/Field/index.tsx +143 -0
- package/src/components/Fieldset/Fieldset.fragment.tsx +166 -0
- package/src/components/Fieldset/Fieldset.module.scss +22 -0
- package/src/components/Fieldset/index.tsx +47 -0
- package/src/components/Form/Form.fragment.tsx +286 -0
- package/src/components/Form/Form.module.scss +8 -0
- package/src/components/Form/index.tsx +53 -0
- package/src/components/Grid/Grid.fragment.tsx +17 -17
- package/src/components/Grid/index.tsx +6 -1
- package/src/components/Header/Header.fragment.tsx +192 -0
- package/src/components/Header/Header.module.scss +209 -0
- package/src/components/Header/index.tsx +363 -0
- package/src/components/Icon/Icon.fragment.tsx +138 -0
- package/src/components/Icon/Icon.module.scss +38 -0
- package/src/components/Icon/index.tsx +58 -0
- package/src/components/Image/Image.fragment.tsx +195 -0
- package/src/components/Image/Image.module.scss +77 -0
- package/src/components/Image/index.tsx +95 -0
- package/src/components/Input/Input.module.scss +75 -2
- package/src/components/Input/index.tsx +60 -21
- package/src/components/Link/Link.fragment.tsx +132 -0
- package/src/components/Link/Link.module.scss +67 -0
- package/src/components/Link/index.tsx +57 -0
- package/src/components/List/List.fragment.tsx +152 -0
- package/src/components/List/List.module.scss +71 -0
- package/src/components/List/index.tsx +106 -0
- package/src/components/Listbox/Listbox.fragment.tsx +191 -0
- package/src/components/Listbox/Listbox.module.scss +97 -0
- package/src/components/Listbox/index.tsx +121 -0
- package/src/components/Menu/Menu.fragment.tsx +9 -0
- package/src/components/Menu/Menu.module.scss +17 -1
- package/src/components/Menu/index.tsx +3 -3
- package/src/components/Popover/Popover.fragment.tsx +9 -0
- package/src/components/Popover/Popover.module.scss +33 -10
- package/src/components/Popover/index.tsx +9 -11
- package/src/components/Progress/Progress.module.scss +11 -11
- package/src/components/Progress/index.tsx +34 -7
- package/src/components/Prompt/Prompt.fragment.tsx +231 -0
- package/src/components/Prompt/Prompt.module.scss +243 -0
- package/src/components/Prompt/index.tsx +439 -0
- package/src/components/RadioGroup/RadioGroup.module.scss +3 -3
- package/src/components/RadioGroup/index.tsx +3 -4
- package/src/components/Select/Select.fragment.tsx +9 -0
- package/src/components/Select/index.tsx +6 -7
- package/src/components/Separator/index.tsx +7 -3
- package/src/components/Sidebar/Sidebar.fragment.tsx +783 -0
- package/src/components/Sidebar/Sidebar.module.scss +586 -0
- package/src/components/Sidebar/index.tsx +1013 -0
- package/src/components/Skeleton/Skeleton.fragment.tsx +5 -5
- package/src/components/Skeleton/Skeleton.module.scss +11 -0
- package/src/components/Slider/Slider.module.scss +87 -0
- package/src/components/Slider/index.tsx +88 -0
- package/src/components/Stack/Stack.module.scss +120 -0
- package/src/components/Stack/index.tsx +148 -0
- package/src/components/Table/Table.fragment.tsx +7 -0
- package/src/components/Table/Table.module.scss +57 -0
- package/src/components/Table/index.tsx +44 -6
- package/src/components/Tabs/Tabs.fragment.tsx +9 -0
- package/src/components/Tabs/Tabs.module.scss +25 -10
- package/src/components/Tabs/index.tsx +11 -8
- package/src/components/Text/Text.module.scss +82 -0
- package/src/components/Text/index.tsx +58 -0
- package/src/components/Textarea/index.tsx +3 -7
- package/src/components/Theme/Theme.fragment.tsx +128 -0
- package/src/components/Theme/ThemeToggle.module.scss +82 -0
- package/src/components/Theme/index.tsx +343 -0
- package/src/components/Toast/Toast.fragment.tsx +5 -5
- package/src/components/Toast/Toast.module.scss +16 -1
- package/src/components/Toast/index.tsx +27 -11
- package/src/components/Toggle/Toggle.module.scss +25 -10
- package/src/components/Toggle/index.tsx +12 -0
- package/src/components/ToggleGroup/ToggleGroup.module.scss +134 -0
- package/src/components/ToggleGroup/index.tsx +144 -0
- package/src/components/Tooltip/Tooltip.module.scss +4 -4
- package/src/components/Tooltip/index.tsx +4 -2
- package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +134 -0
- package/src/components/VisuallyHidden/VisuallyHidden.module.scss +13 -0
- package/src/components/VisuallyHidden/index.tsx +29 -0
- package/src/index.ts +241 -3
- package/src/recipes/AppShell.recipe.ts +175 -0
- package/src/recipes/CardGrid.recipe.ts +6 -2
- package/src/recipes/ChatInterface.recipe.ts +87 -0
- package/src/recipes/CodeExamples.recipe.ts +66 -0
- package/src/recipes/DashboardLayout.recipe.ts +46 -12
- package/src/recipes/DashboardNav.recipe.ts +183 -0
- package/src/recipes/LoginForm.recipe.ts +8 -1
- package/src/recipes/SettingsPage.recipe.ts +37 -20
- package/src/styles/globals.scss +31 -0
- package/src/tokens/_index.scss +3 -0
- package/src/tokens/_mixins.scss +54 -1
- package/src/tokens/_variables.scss +429 -64
- package/src/utils/a11y.tsx +439 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
// ============================================
|
|
6
|
+
// Unique ID Generator
|
|
7
|
+
// ============================================
|
|
8
|
+
|
|
9
|
+
let idCounter = 0;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generates a unique ID for ARIA relationships (labelledby, describedby, etc.)
|
|
13
|
+
* Falls back to React.useId if available (React 18+), otherwise uses a counter.
|
|
14
|
+
*
|
|
15
|
+
* @param prefix - Optional prefix for the ID
|
|
16
|
+
* @returns A unique ID string
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* const id = useId('dialog-title');
|
|
21
|
+
* // Returns: "dialog-title-1" or ":r0:" (React 18)
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function useId(prefix?: string): string {
|
|
25
|
+
// Use React 18's useId if available
|
|
26
|
+
const reactId = React.useId?.();
|
|
27
|
+
|
|
28
|
+
const [id] = React.useState(() => {
|
|
29
|
+
if (reactId) return reactId;
|
|
30
|
+
return `${prefix ? `${prefix}-` : 'fui-'}${++idCounter}`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================
|
|
37
|
+
// Screen Reader Announcements
|
|
38
|
+
// ============================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hook to announce messages to screen readers via a live region.
|
|
42
|
+
* Creates an ARIA live region that persists for the component lifetime.
|
|
43
|
+
*
|
|
44
|
+
* @returns An object with an announce function
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* const { announce } = useAnnounce();
|
|
49
|
+
*
|
|
50
|
+
* // Polite announcement (waits for screen reader to finish)
|
|
51
|
+
* announce('Item added to cart');
|
|
52
|
+
*
|
|
53
|
+
* // Assertive announcement (interrupts immediately)
|
|
54
|
+
* announce('Error: Please fix the form', 'assertive');
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function useAnnounce(): {
|
|
58
|
+
announce: (message: string, priority?: 'polite' | 'assertive') => void;
|
|
59
|
+
} {
|
|
60
|
+
const politeRef = React.useRef<HTMLDivElement | null>(null);
|
|
61
|
+
const assertiveRef = React.useRef<HTMLDivElement | null>(null);
|
|
62
|
+
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
// Create live regions on mount
|
|
65
|
+
const polite = document.createElement('div');
|
|
66
|
+
polite.setAttribute('aria-live', 'polite');
|
|
67
|
+
polite.setAttribute('aria-atomic', 'true');
|
|
68
|
+
polite.setAttribute('role', 'status');
|
|
69
|
+
Object.assign(polite.style, {
|
|
70
|
+
position: 'absolute',
|
|
71
|
+
width: '1px',
|
|
72
|
+
height: '1px',
|
|
73
|
+
padding: '0',
|
|
74
|
+
margin: '-1px',
|
|
75
|
+
overflow: 'hidden',
|
|
76
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
77
|
+
whiteSpace: 'nowrap',
|
|
78
|
+
border: '0',
|
|
79
|
+
});
|
|
80
|
+
document.body.appendChild(polite);
|
|
81
|
+
politeRef.current = polite;
|
|
82
|
+
|
|
83
|
+
const assertive = document.createElement('div');
|
|
84
|
+
assertive.setAttribute('aria-live', 'assertive');
|
|
85
|
+
assertive.setAttribute('aria-atomic', 'true');
|
|
86
|
+
assertive.setAttribute('role', 'alert');
|
|
87
|
+
Object.assign(assertive.style, {
|
|
88
|
+
position: 'absolute',
|
|
89
|
+
width: '1px',
|
|
90
|
+
height: '1px',
|
|
91
|
+
padding: '0',
|
|
92
|
+
margin: '-1px',
|
|
93
|
+
overflow: 'hidden',
|
|
94
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
95
|
+
whiteSpace: 'nowrap',
|
|
96
|
+
border: '0',
|
|
97
|
+
});
|
|
98
|
+
document.body.appendChild(assertive);
|
|
99
|
+
assertiveRef.current = assertive;
|
|
100
|
+
|
|
101
|
+
// Cleanup on unmount
|
|
102
|
+
return () => {
|
|
103
|
+
polite.remove();
|
|
104
|
+
assertive.remove();
|
|
105
|
+
};
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const announce = React.useCallback(
|
|
109
|
+
(message: string, priority: 'polite' | 'assertive' = 'polite') => {
|
|
110
|
+
const region = priority === 'assertive' ? assertiveRef.current : politeRef.current;
|
|
111
|
+
if (region) {
|
|
112
|
+
// Clear and re-set to ensure announcement
|
|
113
|
+
region.textContent = '';
|
|
114
|
+
// Use requestAnimationFrame to ensure the clear is processed
|
|
115
|
+
requestAnimationFrame(() => {
|
|
116
|
+
region.textContent = message;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return { announce };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ============================================
|
|
127
|
+
// User Preference Detection
|
|
128
|
+
// ============================================
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Hook to detect if the user prefers reduced motion.
|
|
132
|
+
* Respects the `prefers-reduced-motion` media query.
|
|
133
|
+
*
|
|
134
|
+
* @returns boolean - true if user prefers reduced motion
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```tsx
|
|
138
|
+
* const prefersReducedMotion = usePrefersReducedMotion();
|
|
139
|
+
*
|
|
140
|
+
* <div style={{
|
|
141
|
+
* transition: prefersReducedMotion ? 'none' : 'transform 200ms'
|
|
142
|
+
* }}>
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export function usePrefersReducedMotion(): boolean {
|
|
146
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(() => {
|
|
147
|
+
// SSR-safe default
|
|
148
|
+
if (typeof window === 'undefined') return false;
|
|
149
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
React.useEffect(() => {
|
|
153
|
+
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
154
|
+
|
|
155
|
+
const handleChange = (event: MediaQueryListEvent) => {
|
|
156
|
+
setPrefersReducedMotion(event.matches);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Modern browsers
|
|
160
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
161
|
+
|
|
162
|
+
// Set initial value
|
|
163
|
+
setPrefersReducedMotion(mediaQuery.matches);
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
mediaQuery.removeEventListener('change', handleChange);
|
|
167
|
+
};
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
return prefersReducedMotion;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hook to detect if the user prefers increased contrast.
|
|
175
|
+
* Respects the `prefers-contrast: more` media query.
|
|
176
|
+
*
|
|
177
|
+
* @returns boolean - true if user prefers high contrast
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```tsx
|
|
181
|
+
* const prefersContrast = usePrefersContrast();
|
|
182
|
+
*
|
|
183
|
+
* <div className={prefersContrast ? styles.highContrast : undefined}>
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function usePrefersContrast(): boolean {
|
|
187
|
+
const [prefersContrast, setPrefersContrast] = React.useState(() => {
|
|
188
|
+
// SSR-safe default
|
|
189
|
+
if (typeof window === 'undefined') return false;
|
|
190
|
+
return window.matchMedia('(prefers-contrast: more)').matches;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
React.useEffect(() => {
|
|
194
|
+
const mediaQuery = window.matchMedia('(prefers-contrast: more)');
|
|
195
|
+
|
|
196
|
+
const handleChange = (event: MediaQueryListEvent) => {
|
|
197
|
+
setPrefersContrast(event.matches);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
201
|
+
setPrefersContrast(mediaQuery.matches);
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
mediaQuery.removeEventListener('change', handleChange);
|
|
205
|
+
};
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
return prefersContrast;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================
|
|
212
|
+
// Focus Management
|
|
213
|
+
// ============================================
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Hook to trap focus within a container element.
|
|
217
|
+
* Useful for modals, dialogs, and other overlays.
|
|
218
|
+
*
|
|
219
|
+
* @param ref - React ref to the container element
|
|
220
|
+
* @param active - Whether focus trap is active
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```tsx
|
|
224
|
+
* const dialogRef = useRef<HTMLDivElement>(null);
|
|
225
|
+
* useFocusTrap(dialogRef, isOpen);
|
|
226
|
+
*
|
|
227
|
+
* return <div ref={dialogRef}>...</div>;
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
export function useFocusTrap(
|
|
231
|
+
ref: React.RefObject<HTMLElement | null>,
|
|
232
|
+
active: boolean
|
|
233
|
+
): void {
|
|
234
|
+
const previousActiveElement = React.useRef<Element | null>(null);
|
|
235
|
+
|
|
236
|
+
React.useEffect(() => {
|
|
237
|
+
if (!active || !ref.current) return;
|
|
238
|
+
|
|
239
|
+
const container = ref.current;
|
|
240
|
+
previousActiveElement.current = document.activeElement;
|
|
241
|
+
|
|
242
|
+
// Focus the first focusable element
|
|
243
|
+
const focusableElements = getFocusableElements(container);
|
|
244
|
+
if (focusableElements.length > 0) {
|
|
245
|
+
(focusableElements[0] as HTMLElement).focus();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
249
|
+
if (event.key !== 'Tab') return;
|
|
250
|
+
|
|
251
|
+
const focusable = getFocusableElements(container);
|
|
252
|
+
if (focusable.length === 0) return;
|
|
253
|
+
|
|
254
|
+
const firstFocusable = focusable[0] as HTMLElement;
|
|
255
|
+
const lastFocusable = focusable[focusable.length - 1] as HTMLElement;
|
|
256
|
+
|
|
257
|
+
if (event.shiftKey) {
|
|
258
|
+
// Shift + Tab: move focus backward
|
|
259
|
+
if (document.activeElement === firstFocusable) {
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
lastFocusable.focus();
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
// Tab: move focus forward
|
|
265
|
+
if (document.activeElement === lastFocusable) {
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
firstFocusable.focus();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
273
|
+
|
|
274
|
+
return () => {
|
|
275
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
276
|
+
|
|
277
|
+
// Restore focus when trap is deactivated
|
|
278
|
+
if (previousActiveElement.current instanceof HTMLElement) {
|
|
279
|
+
previousActiveElement.current.focus();
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}, [active, ref]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get all focusable elements within a container
|
|
287
|
+
*/
|
|
288
|
+
function getFocusableElements(container: HTMLElement): NodeListOf<Element> {
|
|
289
|
+
return container.querySelectorAll(
|
|
290
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ============================================
|
|
295
|
+
// Keyboard Navigation Helpers
|
|
296
|
+
// ============================================
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Helper to handle keyboard navigation within a group of elements.
|
|
300
|
+
* Supports arrow keys, Home, End, and optional type-ahead.
|
|
301
|
+
*
|
|
302
|
+
* @param event - Keyboard event
|
|
303
|
+
* @param items - Array of focusable items
|
|
304
|
+
* @param currentIndex - Current focused item index
|
|
305
|
+
* @param options - Navigation options
|
|
306
|
+
* @returns New index or undefined if no navigation occurred
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```tsx
|
|
310
|
+
* const handleKeyDown = (event: KeyboardEvent) => {
|
|
311
|
+
* const newIndex = handleArrowNavigation(event, menuItems, currentIndex, {
|
|
312
|
+
* orientation: 'vertical',
|
|
313
|
+
* loop: true,
|
|
314
|
+
* });
|
|
315
|
+
* if (newIndex !== undefined) {
|
|
316
|
+
* setCurrentIndex(newIndex);
|
|
317
|
+
* }
|
|
318
|
+
* };
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
export function handleArrowNavigation(
|
|
322
|
+
event: React.KeyboardEvent | KeyboardEvent,
|
|
323
|
+
items: readonly unknown[],
|
|
324
|
+
currentIndex: number,
|
|
325
|
+
options: {
|
|
326
|
+
orientation?: 'horizontal' | 'vertical' | 'both';
|
|
327
|
+
loop?: boolean;
|
|
328
|
+
} = {}
|
|
329
|
+
): number | undefined {
|
|
330
|
+
const { orientation = 'vertical', loop = true } = options;
|
|
331
|
+
const length = items.length;
|
|
332
|
+
|
|
333
|
+
if (length === 0) return undefined;
|
|
334
|
+
|
|
335
|
+
let newIndex: number | undefined;
|
|
336
|
+
|
|
337
|
+
switch (event.key) {
|
|
338
|
+
case 'ArrowDown':
|
|
339
|
+
if (orientation === 'horizontal') return undefined;
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
newIndex = currentIndex + 1;
|
|
342
|
+
if (newIndex >= length) {
|
|
343
|
+
newIndex = loop ? 0 : length - 1;
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
|
|
347
|
+
case 'ArrowUp':
|
|
348
|
+
if (orientation === 'horizontal') return undefined;
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
newIndex = currentIndex - 1;
|
|
351
|
+
if (newIndex < 0) {
|
|
352
|
+
newIndex = loop ? length - 1 : 0;
|
|
353
|
+
}
|
|
354
|
+
break;
|
|
355
|
+
|
|
356
|
+
case 'ArrowRight':
|
|
357
|
+
if (orientation === 'vertical') return undefined;
|
|
358
|
+
event.preventDefault();
|
|
359
|
+
newIndex = currentIndex + 1;
|
|
360
|
+
if (newIndex >= length) {
|
|
361
|
+
newIndex = loop ? 0 : length - 1;
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
|
|
365
|
+
case 'ArrowLeft':
|
|
366
|
+
if (orientation === 'vertical') return undefined;
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
newIndex = currentIndex - 1;
|
|
369
|
+
if (newIndex < 0) {
|
|
370
|
+
newIndex = loop ? length - 1 : 0;
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
|
|
374
|
+
case 'Home':
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
newIndex = 0;
|
|
377
|
+
break;
|
|
378
|
+
|
|
379
|
+
case 'End':
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
newIndex = length - 1;
|
|
382
|
+
break;
|
|
383
|
+
|
|
384
|
+
default:
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return newIndex;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ============================================
|
|
392
|
+
// Screen Reader Only Component
|
|
393
|
+
// ============================================
|
|
394
|
+
|
|
395
|
+
export interface VisuallyHiddenProps {
|
|
396
|
+
children: React.ReactNode;
|
|
397
|
+
/** If true, becomes visible when focused (useful for skip links) */
|
|
398
|
+
focusable?: boolean;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Visually hides content while keeping it accessible to screen readers.
|
|
403
|
+
* Use for skip links, additional context, or off-screen labels.
|
|
404
|
+
*
|
|
405
|
+
* @example
|
|
406
|
+
* ```tsx
|
|
407
|
+
* // Hidden label for icon-only button
|
|
408
|
+
* <button>
|
|
409
|
+
* <Icon name="search" />
|
|
410
|
+
* <VisuallyHidden>Search</VisuallyHidden>
|
|
411
|
+
* </button>
|
|
412
|
+
*
|
|
413
|
+
* // Skip link that shows on focus
|
|
414
|
+
* <VisuallyHidden focusable>
|
|
415
|
+
* <a href="#main">Skip to main content</a>
|
|
416
|
+
* </VisuallyHidden>
|
|
417
|
+
* ```
|
|
418
|
+
*/
|
|
419
|
+
export function VisuallyHidden({ children, focusable = false }: VisuallyHiddenProps): React.ReactElement {
|
|
420
|
+
const style: React.CSSProperties = focusable
|
|
421
|
+
? {}
|
|
422
|
+
: {
|
|
423
|
+
position: 'absolute',
|
|
424
|
+
width: '1px',
|
|
425
|
+
height: '1px',
|
|
426
|
+
padding: '0',
|
|
427
|
+
margin: '-1px',
|
|
428
|
+
overflow: 'hidden',
|
|
429
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
430
|
+
whiteSpace: 'nowrap',
|
|
431
|
+
border: '0',
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<span style={style} data-visually-hidden={!focusable || undefined}>
|
|
436
|
+
{children}
|
|
437
|
+
</span>
|
|
438
|
+
);
|
|
439
|
+
}
|