@idealyst/components 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/package.json +8 -3
  2. package/src/Accordion/Accordion.native.tsx +23 -2
  3. package/src/Accordion/Accordion.web.tsx +73 -2
  4. package/src/Accordion/types.ts +2 -1
  5. package/src/ActivityIndicator/ActivityIndicator.native.tsx +15 -1
  6. package/src/ActivityIndicator/ActivityIndicator.web.tsx +19 -2
  7. package/src/ActivityIndicator/types.ts +2 -1
  8. package/src/Avatar/Avatar.native.tsx +19 -2
  9. package/src/Avatar/Avatar.web.tsx +19 -2
  10. package/src/Avatar/types.ts +2 -1
  11. package/src/Breadcrumb/types.ts +3 -2
  12. package/src/Button/Button.native.tsx +48 -1
  13. package/src/Button/Button.styles.tsx +3 -5
  14. package/src/Button/Button.web.tsx +61 -2
  15. package/src/Button/types.ts +2 -1
  16. package/src/Card/Card.native.tsx +21 -5
  17. package/src/Card/Card.web.tsx +21 -4
  18. package/src/Card/types.ts +2 -6
  19. package/src/Checkbox/Checkbox.native.tsx +46 -5
  20. package/src/Checkbox/Checkbox.web.tsx +80 -4
  21. package/src/Checkbox/types.ts +2 -6
  22. package/src/Chip/Chip.native.tsx +5 -0
  23. package/src/Chip/Chip.web.tsx +5 -1
  24. package/src/Chip/types.ts +2 -1
  25. package/src/Dialog/Dialog.native.tsx +20 -3
  26. package/src/Dialog/Dialog.web.tsx +29 -4
  27. package/src/Dialog/types.ts +2 -1
  28. package/src/Image/Image.native.tsx +1 -1
  29. package/src/Image/Image.web.tsx +2 -0
  30. package/src/Input/Input.native.tsx +37 -1
  31. package/src/Input/Input.web.tsx +75 -8
  32. package/src/Input/types.ts +2 -1
  33. package/src/List/List.native.tsx +18 -2
  34. package/src/List/ListItem.native.tsx +44 -8
  35. package/src/List/ListItem.web.tsx +16 -0
  36. package/src/List/types.ts +6 -3
  37. package/src/Menu/Menu.native.tsx +21 -2
  38. package/src/Menu/Menu.web.tsx +110 -3
  39. package/src/Menu/MenuItem.web.tsx +12 -3
  40. package/src/Menu/types.ts +2 -1
  41. package/src/Popover/Popover.native.tsx +17 -1
  42. package/src/Popover/Popover.web.tsx +31 -2
  43. package/src/Popover/types.ts +2 -1
  44. package/src/RadioButton/RadioButton.native.tsx +41 -3
  45. package/src/RadioButton/RadioButton.web.tsx +45 -6
  46. package/src/RadioButton/RadioGroup.native.tsx +20 -2
  47. package/src/RadioButton/RadioGroup.web.tsx +24 -3
  48. package/src/RadioButton/types.ts +3 -2
  49. package/src/Select/types.ts +2 -6
  50. package/src/Skeleton/Skeleton.native.tsx +15 -1
  51. package/src/Skeleton/Skeleton.web.tsx +20 -1
  52. package/src/Skeleton/types.ts +2 -1
  53. package/src/Slider/Slider.native.tsx +42 -2
  54. package/src/Slider/Slider.web.tsx +81 -7
  55. package/src/Slider/types.ts +2 -1
  56. package/src/Switch/Switch.native.tsx +41 -3
  57. package/src/Switch/Switch.web.tsx +45 -5
  58. package/src/Switch/types.ts +2 -1
  59. package/src/TabBar/TabBar.native.tsx +23 -2
  60. package/src/TabBar/TabBar.web.tsx +71 -2
  61. package/src/TabBar/types.ts +2 -1
  62. package/src/Table/Table.native.tsx +17 -1
  63. package/src/Table/Table.web.tsx +20 -3
  64. package/src/Table/types.ts +3 -2
  65. package/src/TextArea/TextArea.native.tsx +50 -1
  66. package/src/TextArea/TextArea.web.tsx +82 -6
  67. package/src/TextArea/types.ts +2 -1
  68. package/src/Tooltip/Tooltip.native.tsx +19 -2
  69. package/src/Tooltip/Tooltip.web.tsx +54 -2
  70. package/src/Tooltip/types.ts +2 -1
  71. package/src/Video/Video.native.tsx +18 -3
  72. package/src/Video/Video.web.tsx +17 -1
  73. package/src/Video/types.ts +2 -1
  74. package/src/examples/InputExamples.tsx +53 -0
  75. package/src/examples/ListExamples.tsx +34 -0
  76. package/src/internal/index.ts +2 -0
  77. package/src/utils/accessibility/ariaHelpers.ts +393 -0
  78. package/src/utils/accessibility/index.ts +210 -0
  79. package/src/utils/accessibility/keyboardPatterns.ts +263 -0
  80. package/src/utils/accessibility/types.ts +223 -0
  81. package/src/utils/accessibility/useAnnounce.ts +210 -0
  82. package/src/utils/accessibility/useFocusTrap.ts +265 -0
  83. package/src/utils/accessibility/useKeyboardNavigation.ts +292 -0
  84. package/src/utils/index.ts +3 -0
@@ -0,0 +1,265 @@
1
+ import { useEffect, useRef, useCallback } from 'react';
2
+
3
+ /**
4
+ * Options for the useFocusTrap hook.
5
+ */
6
+ export interface UseFocusTrapOptions {
7
+ /** Whether the focus trap is active */
8
+ active: boolean;
9
+ /** Ref to the container element that traps focus */
10
+ containerRef: React.RefObject<HTMLElement>;
11
+ /** Whether to restore focus to the previously focused element when deactivated */
12
+ restoreFocus?: boolean;
13
+ /** Whether to auto-focus the first focusable element when activated */
14
+ autoFocus?: boolean;
15
+ /** CSS selector for the element to focus initially (overrides autoFocus) */
16
+ initialFocus?: string;
17
+ /** CSS selector for the element to focus when closing (overrides restoreFocus) */
18
+ returnFocus?: string;
19
+ /** Callback when Escape key is pressed */
20
+ onEscape?: () => void;
21
+ }
22
+
23
+ /**
24
+ * Return type for the useFocusTrap hook.
25
+ */
26
+ export interface UseFocusTrapReturn {
27
+ /** Manually focus the first focusable element in the container */
28
+ focusFirst: () => void;
29
+ /** Manually focus the last focusable element in the container */
30
+ focusLast: () => void;
31
+ /** Get all focusable elements within the container */
32
+ getFocusableElements: () => HTMLElement[];
33
+ }
34
+
35
+ /**
36
+ * Selector for focusable elements.
37
+ */
38
+ const FOCUSABLE_SELECTOR = [
39
+ 'a[href]',
40
+ 'button:not([disabled])',
41
+ 'textarea:not([disabled])',
42
+ 'input:not([disabled]):not([type="hidden"])',
43
+ 'select:not([disabled])',
44
+ '[tabindex]:not([tabindex="-1"])',
45
+ '[contenteditable="true"]',
46
+ ].join(',');
47
+
48
+ /**
49
+ * Hook for implementing WCAG-compliant focus trapping in dialogs and modals.
50
+ *
51
+ * Features:
52
+ * - Traps focus within a container element
53
+ * - Tab cycles through focusable elements
54
+ * - Shift+Tab cycles in reverse
55
+ * - Auto-focuses first element on activation
56
+ * - Restores focus on deactivation
57
+ * - Escape key handling
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * const containerRef = useRef<HTMLDivElement>(null);
62
+ * const [isOpen, setIsOpen] = useState(false);
63
+ *
64
+ * const { focusFirst } = useFocusTrap({
65
+ * active: isOpen,
66
+ * containerRef,
67
+ * onEscape: () => setIsOpen(false),
68
+ * });
69
+ *
70
+ * return (
71
+ * <div
72
+ * ref={containerRef}
73
+ * role="dialog"
74
+ * aria-modal="true"
75
+ * >
76
+ * <button>Focusable</button>
77
+ * <button onClick={() => setIsOpen(false)}>Close</button>
78
+ * </div>
79
+ * );
80
+ * ```
81
+ */
82
+ export function useFocusTrap({
83
+ active,
84
+ containerRef,
85
+ restoreFocus = true,
86
+ autoFocus = true,
87
+ initialFocus,
88
+ returnFocus,
89
+ onEscape,
90
+ }: UseFocusTrapOptions): UseFocusTrapReturn {
91
+ // Store the element that was focused before the trap was activated
92
+ const previousActiveElement = useRef<HTMLElement | null>(null);
93
+
94
+ /**
95
+ * Get all focusable elements within the container.
96
+ */
97
+ const getFocusableElements = useCallback((): HTMLElement[] => {
98
+ if (!containerRef.current) return [];
99
+
100
+ const elements = Array.from(
101
+ containerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
102
+ );
103
+
104
+ // Filter out elements that are not visible or are disabled
105
+ return elements.filter((el) => {
106
+ // Check if element is visible
107
+ if (el.offsetParent === null && el.style.position !== 'fixed') {
108
+ return false;
109
+ }
110
+ // Check computed visibility
111
+ const style = window.getComputedStyle(el);
112
+ if (style.visibility === 'hidden' || style.display === 'none') {
113
+ return false;
114
+ }
115
+ return true;
116
+ });
117
+ }, [containerRef]);
118
+
119
+ /**
120
+ * Focus the first focusable element in the container.
121
+ */
122
+ const focusFirst = useCallback(() => {
123
+ const container = containerRef.current;
124
+ if (!container) return;
125
+
126
+ // Try to focus the initial focus element first
127
+ if (initialFocus) {
128
+ const initialElement = container.querySelector<HTMLElement>(initialFocus);
129
+ if (initialElement) {
130
+ initialElement.focus();
131
+ return;
132
+ }
133
+ }
134
+
135
+ // Otherwise focus the first focusable element
136
+ const focusable = getFocusableElements();
137
+ if (focusable.length > 0) {
138
+ focusable[0].focus();
139
+ } else {
140
+ // If no focusable elements, focus the container itself
141
+ container.setAttribute('tabindex', '-1');
142
+ container.focus();
143
+ }
144
+ }, [containerRef, initialFocus, getFocusableElements]);
145
+
146
+ /**
147
+ * Focus the last focusable element in the container.
148
+ */
149
+ const focusLast = useCallback(() => {
150
+ const focusable = getFocusableElements();
151
+ if (focusable.length > 0) {
152
+ focusable[focusable.length - 1].focus();
153
+ }
154
+ }, [getFocusableElements]);
155
+
156
+ /**
157
+ * Handle Tab key to trap focus within the container.
158
+ */
159
+ const handleKeyDown = useCallback(
160
+ (event: KeyboardEvent) => {
161
+ if (!active) return;
162
+
163
+ if (event.key === 'Escape') {
164
+ event.preventDefault();
165
+ onEscape?.();
166
+ return;
167
+ }
168
+
169
+ if (event.key !== 'Tab') return;
170
+
171
+ const focusable = getFocusableElements();
172
+ if (focusable.length === 0) return;
173
+
174
+ const firstFocusable = focusable[0];
175
+ const lastFocusable = focusable[focusable.length - 1];
176
+ const activeElement = document.activeElement;
177
+
178
+ if (event.shiftKey) {
179
+ // Shift + Tab - going backwards
180
+ if (activeElement === firstFocusable || !containerRef.current?.contains(activeElement)) {
181
+ event.preventDefault();
182
+ lastFocusable.focus();
183
+ }
184
+ } else {
185
+ // Tab - going forwards
186
+ if (activeElement === lastFocusable || !containerRef.current?.contains(activeElement)) {
187
+ event.preventDefault();
188
+ firstFocusable.focus();
189
+ }
190
+ }
191
+ },
192
+ [active, getFocusableElements, containerRef, onEscape]
193
+ );
194
+
195
+ /**
196
+ * Handle clicks outside the container (optional - for click-away to close).
197
+ */
198
+ const handleFocusIn = useCallback(
199
+ (event: FocusEvent) => {
200
+ if (!active || !containerRef.current) return;
201
+
202
+ // If focus moves outside the container, bring it back
203
+ if (!containerRef.current.contains(event.target as Node)) {
204
+ focusFirst();
205
+ }
206
+ },
207
+ [active, containerRef, focusFirst]
208
+ );
209
+
210
+ // Set up and tear down the focus trap
211
+ useEffect(() => {
212
+ if (active) {
213
+ // Store currently focused element before trapping
214
+ previousActiveElement.current = document.activeElement as HTMLElement;
215
+
216
+ // Add event listeners
217
+ document.addEventListener('keydown', handleKeyDown);
218
+ document.addEventListener('focusin', handleFocusIn);
219
+
220
+ // Auto-focus after a short delay to ensure the container is rendered
221
+ if (autoFocus) {
222
+ requestAnimationFrame(() => {
223
+ focusFirst();
224
+ });
225
+ }
226
+ }
227
+
228
+ return () => {
229
+ document.removeEventListener('keydown', handleKeyDown);
230
+ document.removeEventListener('focusin', handleFocusIn);
231
+
232
+ // Restore focus when deactivating
233
+ if (active && restoreFocus) {
234
+ if (returnFocus) {
235
+ const returnElement = document.querySelector<HTMLElement>(returnFocus);
236
+ if (returnElement) {
237
+ returnElement.focus();
238
+ return;
239
+ }
240
+ }
241
+ if (previousActiveElement.current && previousActiveElement.current.focus) {
242
+ previousActiveElement.current.focus();
243
+ }
244
+ }
245
+ };
246
+ }, [active, autoFocus, restoreFocus, returnFocus, handleKeyDown, handleFocusIn, focusFirst]);
247
+
248
+ return {
249
+ focusFirst,
250
+ focusLast,
251
+ getFocusableElements,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Native version of useFocusTrap - no-op since React Native handles
257
+ * modal focus differently via the Modal component.
258
+ */
259
+ export function useFocusTrapNative(): UseFocusTrapReturn {
260
+ return {
261
+ focusFirst: () => {},
262
+ focusLast: () => {},
263
+ getFocusableElements: () => [],
264
+ };
265
+ }
@@ -0,0 +1,292 @@
1
+ import { useCallback, useRef } from 'react';
2
+
3
+ /**
4
+ * Options for the useKeyboardNavigation hook.
5
+ */
6
+ export interface UseKeyboardNavigationOptions {
7
+ /** Refs to the navigable items */
8
+ itemRefs: React.RefObject<(HTMLElement | null)[]>;
9
+ /** Currently focused item index */
10
+ focusedIndex: number;
11
+ /** Callback to update the focused index */
12
+ setFocusedIndex: (index: number) => void;
13
+ /** Total number of items */
14
+ itemCount: number;
15
+ /** Navigation orientation - determines which arrow keys are used */
16
+ orientation?: 'horizontal' | 'vertical' | 'both';
17
+ /** Whether navigation wraps from last to first and vice versa */
18
+ wrap?: boolean;
19
+ /** Callback when an item is selected (Enter/Space pressed) */
20
+ onSelect?: (index: number) => void;
21
+ /** Callback when Escape is pressed */
22
+ onEscape?: () => void;
23
+ /** Function to get the text label of an item for type-ahead search */
24
+ getItemLabel?: (index: number) => string;
25
+ /** Whether navigation is disabled */
26
+ disabled?: boolean;
27
+ /** Whether to auto-select on navigation (like tabs with automatic activation) */
28
+ autoSelect?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Return type for the useKeyboardNavigation hook.
33
+ */
34
+ export interface UseKeyboardNavigationReturn {
35
+ /** Keyboard event handler to attach to the container or items */
36
+ handleKeyDown: (event: React.KeyboardEvent) => void;
37
+ /** Programmatically focus an item by index */
38
+ focusItem: (index: number) => void;
39
+ /** Navigate to the next item */
40
+ navigateNext: () => void;
41
+ /** Navigate to the previous item */
42
+ navigatePrevious: () => void;
43
+ /** Navigate to the first item */
44
+ navigateFirst: () => void;
45
+ /** Navigate to the last item */
46
+ navigateLast: () => void;
47
+ }
48
+
49
+ /**
50
+ * Hook for implementing WCAG-compliant keyboard navigation in lists, menus, tabs, etc.
51
+ *
52
+ * Features:
53
+ * - Arrow key navigation (horizontal, vertical, or both)
54
+ * - Home/End for first/last item
55
+ * - Enter/Space for selection
56
+ * - Escape for closing/canceling
57
+ * - Type-ahead search for quick navigation
58
+ * - Optional wrap-around navigation
59
+ * - Auto-select on navigation (for tabs)
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * const itemRefs = useRef<(HTMLElement | null)[]>([]);
64
+ * const [focusedIndex, setFocusedIndex] = useState(0);
65
+ *
66
+ * const { handleKeyDown, focusItem } = useKeyboardNavigation({
67
+ * itemRefs,
68
+ * focusedIndex,
69
+ * setFocusedIndex,
70
+ * itemCount: items.length,
71
+ * orientation: 'vertical',
72
+ * onSelect: (index) => handleItemSelect(items[index]),
73
+ * });
74
+ *
75
+ * return (
76
+ * <div role="listbox" onKeyDown={handleKeyDown}>
77
+ * {items.map((item, i) => (
78
+ * <div
79
+ * key={i}
80
+ * ref={(el) => { itemRefs.current[i] = el; }}
81
+ * role="option"
82
+ * tabIndex={i === focusedIndex ? 0 : -1}
83
+ * >
84
+ * {item}
85
+ * </div>
86
+ * ))}
87
+ * </div>
88
+ * );
89
+ * ```
90
+ */
91
+ export function useKeyboardNavigation({
92
+ itemRefs,
93
+ focusedIndex,
94
+ setFocusedIndex,
95
+ itemCount,
96
+ orientation = 'vertical',
97
+ wrap = true,
98
+ onSelect,
99
+ onEscape,
100
+ getItemLabel,
101
+ disabled = false,
102
+ autoSelect = false,
103
+ }: UseKeyboardNavigationOptions): UseKeyboardNavigationReturn {
104
+ // Buffer for type-ahead search
105
+ const searchBuffer = useRef('');
106
+ const searchTimeout = useRef<ReturnType<typeof setTimeout>>();
107
+
108
+ /**
109
+ * Focus a specific item by index.
110
+ */
111
+ const focusItem = useCallback(
112
+ (index: number) => {
113
+ const items = itemRefs.current;
114
+ if (items && items[index]) {
115
+ items[index]?.focus();
116
+ setFocusedIndex(index);
117
+ if (autoSelect) {
118
+ onSelect?.(index);
119
+ }
120
+ }
121
+ },
122
+ [itemRefs, setFocusedIndex, autoSelect, onSelect]
123
+ );
124
+
125
+ /**
126
+ * Navigate to the next item.
127
+ */
128
+ const navigateNext = useCallback(() => {
129
+ if (disabled || itemCount === 0) return;
130
+ const nextIndex = focusedIndex + 1;
131
+ if (nextIndex >= itemCount) {
132
+ if (wrap) focusItem(0);
133
+ } else {
134
+ focusItem(nextIndex);
135
+ }
136
+ }, [disabled, itemCount, focusedIndex, wrap, focusItem]);
137
+
138
+ /**
139
+ * Navigate to the previous item.
140
+ */
141
+ const navigatePrevious = useCallback(() => {
142
+ if (disabled || itemCount === 0) return;
143
+ const prevIndex = focusedIndex - 1;
144
+ if (prevIndex < 0) {
145
+ if (wrap) focusItem(itemCount - 1);
146
+ } else {
147
+ focusItem(prevIndex);
148
+ }
149
+ }, [disabled, itemCount, focusedIndex, wrap, focusItem]);
150
+
151
+ /**
152
+ * Navigate to the first item.
153
+ */
154
+ const navigateFirst = useCallback(() => {
155
+ if (disabled || itemCount === 0) return;
156
+ focusItem(0);
157
+ }, [disabled, itemCount, focusItem]);
158
+
159
+ /**
160
+ * Navigate to the last item.
161
+ */
162
+ const navigateLast = useCallback(() => {
163
+ if (disabled || itemCount === 0) return;
164
+ focusItem(itemCount - 1);
165
+ }, [disabled, itemCount, focusItem]);
166
+
167
+ /**
168
+ * Handle type-ahead search - jump to item starting with typed character(s).
169
+ */
170
+ const handleTypeAhead = useCallback(
171
+ (char: string) => {
172
+ if (!getItemLabel || disabled) return;
173
+
174
+ // Clear existing timeout
175
+ if (searchTimeout.current) {
176
+ clearTimeout(searchTimeout.current);
177
+ }
178
+
179
+ // Append character to search buffer
180
+ searchBuffer.current += char.toLowerCase();
181
+
182
+ // Find matching item starting from current index + 1
183
+ for (let i = 0; i < itemCount; i++) {
184
+ const index = (focusedIndex + i + 1) % itemCount;
185
+ const label = getItemLabel(index).toLowerCase();
186
+ if (label.startsWith(searchBuffer.current)) {
187
+ focusItem(index);
188
+ break;
189
+ }
190
+ }
191
+
192
+ // Clear buffer after 500ms of inactivity (standard type-ahead delay)
193
+ searchTimeout.current = setTimeout(() => {
194
+ searchBuffer.current = '';
195
+ }, 500);
196
+ },
197
+ [getItemLabel, disabled, itemCount, focusedIndex, focusItem]
198
+ );
199
+
200
+ /**
201
+ * Main keyboard event handler.
202
+ */
203
+ const handleKeyDown = useCallback(
204
+ (event: React.KeyboardEvent) => {
205
+ if (disabled) return;
206
+
207
+ const { key } = event;
208
+
209
+ // Handle arrow keys based on orientation
210
+ if (orientation === 'vertical' || orientation === 'both') {
211
+ if (key === 'ArrowDown') {
212
+ event.preventDefault();
213
+ navigateNext();
214
+ return;
215
+ }
216
+ if (key === 'ArrowUp') {
217
+ event.preventDefault();
218
+ navigatePrevious();
219
+ return;
220
+ }
221
+ }
222
+
223
+ if (orientation === 'horizontal' || orientation === 'both') {
224
+ if (key === 'ArrowRight') {
225
+ event.preventDefault();
226
+ navigateNext();
227
+ return;
228
+ }
229
+ if (key === 'ArrowLeft') {
230
+ event.preventDefault();
231
+ navigatePrevious();
232
+ return;
233
+ }
234
+ }
235
+
236
+ // Common navigation keys
237
+ switch (key) {
238
+ case 'Home':
239
+ event.preventDefault();
240
+ navigateFirst();
241
+ break;
242
+
243
+ case 'End':
244
+ event.preventDefault();
245
+ navigateLast();
246
+ break;
247
+
248
+ case 'Enter':
249
+ case ' ':
250
+ // Space should only select if we're not in a text input
251
+ if (key === ' ' && event.target instanceof HTMLInputElement) {
252
+ return; // Let the input handle the space
253
+ }
254
+ event.preventDefault();
255
+ onSelect?.(focusedIndex);
256
+ break;
257
+
258
+ case 'Escape':
259
+ event.preventDefault();
260
+ onEscape?.();
261
+ break;
262
+
263
+ default:
264
+ // Type-ahead for printable characters
265
+ if (key.length === 1 && /[a-zA-Z0-9]/.test(key)) {
266
+ handleTypeAhead(key);
267
+ }
268
+ }
269
+ },
270
+ [
271
+ disabled,
272
+ orientation,
273
+ navigateNext,
274
+ navigatePrevious,
275
+ navigateFirst,
276
+ navigateLast,
277
+ onSelect,
278
+ onEscape,
279
+ focusedIndex,
280
+ handleTypeAhead,
281
+ ]
282
+ );
283
+
284
+ return {
285
+ handleKeyDown,
286
+ focusItem,
287
+ navigateNext,
288
+ navigatePrevious,
289
+ navigateFirst,
290
+ navigateLast,
291
+ };
292
+ }
@@ -18,3 +18,6 @@ export {
18
18
 
19
19
  // General style helpers
20
20
  export { deepMerge, isPlainObject } from './styleHelpers';
21
+
22
+ // Accessibility utilities
23
+ export * from './accessibility';