@idealyst/components 1.1.4 → 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.
- package/package.json +8 -3
- package/src/Accordion/Accordion.native.tsx +23 -2
- package/src/Accordion/Accordion.web.tsx +73 -2
- package/src/Accordion/types.ts +2 -1
- package/src/ActivityIndicator/ActivityIndicator.native.tsx +15 -1
- package/src/ActivityIndicator/ActivityIndicator.web.tsx +19 -2
- package/src/ActivityIndicator/types.ts +2 -1
- package/src/Avatar/Avatar.native.tsx +19 -2
- package/src/Avatar/Avatar.web.tsx +19 -2
- package/src/Avatar/types.ts +2 -1
- package/src/Breadcrumb/types.ts +3 -2
- package/src/Button/Button.native.tsx +48 -1
- package/src/Button/Button.styles.tsx +3 -5
- package/src/Button/Button.web.tsx +61 -2
- package/src/Button/types.ts +2 -1
- package/src/Card/Card.native.tsx +21 -5
- package/src/Card/Card.web.tsx +21 -4
- package/src/Card/types.ts +2 -6
- package/src/Checkbox/Checkbox.native.tsx +46 -5
- package/src/Checkbox/Checkbox.web.tsx +80 -4
- package/src/Checkbox/types.ts +2 -6
- package/src/Chip/Chip.native.tsx +5 -0
- package/src/Chip/Chip.web.tsx +5 -1
- package/src/Chip/types.ts +2 -1
- package/src/Dialog/Dialog.native.tsx +20 -3
- package/src/Dialog/Dialog.web.tsx +29 -4
- package/src/Dialog/types.ts +2 -1
- package/src/Image/Image.native.tsx +1 -1
- package/src/Image/Image.web.tsx +2 -0
- package/src/Input/Input.native.tsx +37 -1
- package/src/Input/Input.web.tsx +75 -8
- package/src/Input/types.ts +2 -1
- package/src/List/List.native.tsx +18 -2
- package/src/List/ListItem.native.tsx +44 -8
- package/src/List/ListItem.web.tsx +16 -0
- package/src/List/types.ts +6 -3
- package/src/Menu/Menu.native.tsx +21 -2
- package/src/Menu/Menu.web.tsx +110 -3
- package/src/Menu/MenuItem.web.tsx +12 -3
- package/src/Menu/types.ts +2 -1
- package/src/Popover/Popover.native.tsx +17 -1
- package/src/Popover/Popover.web.tsx +31 -2
- package/src/Popover/types.ts +2 -1
- package/src/RadioButton/RadioButton.native.tsx +41 -3
- package/src/RadioButton/RadioButton.web.tsx +45 -6
- package/src/RadioButton/RadioGroup.native.tsx +20 -2
- package/src/RadioButton/RadioGroup.web.tsx +24 -3
- package/src/RadioButton/types.ts +3 -2
- package/src/Select/types.ts +2 -6
- package/src/Skeleton/Skeleton.native.tsx +15 -1
- package/src/Skeleton/Skeleton.web.tsx +20 -1
- package/src/Skeleton/types.ts +2 -1
- package/src/Slider/Slider.native.tsx +42 -2
- package/src/Slider/Slider.web.tsx +81 -7
- package/src/Slider/types.ts +2 -1
- package/src/Switch/Switch.native.tsx +41 -3
- package/src/Switch/Switch.web.tsx +45 -5
- package/src/Switch/types.ts +2 -1
- package/src/TabBar/TabBar.native.tsx +23 -2
- package/src/TabBar/TabBar.web.tsx +71 -2
- package/src/TabBar/types.ts +2 -1
- package/src/Table/Table.native.tsx +17 -1
- package/src/Table/Table.web.tsx +20 -3
- package/src/Table/types.ts +3 -2
- package/src/TextArea/TextArea.native.tsx +50 -1
- package/src/TextArea/TextArea.web.tsx +82 -6
- package/src/TextArea/types.ts +2 -1
- package/src/Tooltip/Tooltip.native.tsx +19 -2
- package/src/Tooltip/Tooltip.web.tsx +54 -2
- package/src/Tooltip/types.ts +2 -1
- package/src/Video/Video.native.tsx +18 -3
- package/src/Video/Video.web.tsx +17 -1
- package/src/Video/types.ts +2 -1
- package/src/examples/InputExamples.tsx +53 -0
- package/src/examples/ListExamples.tsx +34 -0
- package/src/internal/index.ts +2 -0
- package/src/utils/accessibility/ariaHelpers.ts +393 -0
- package/src/utils/accessibility/index.ts +210 -0
- package/src/utils/accessibility/keyboardPatterns.ts +263 -0
- package/src/utils/accessibility/types.ts +223 -0
- package/src/utils/accessibility/useAnnounce.ts +210 -0
- package/src/utils/accessibility/useFocusTrap.ts +265 -0
- package/src/utils/accessibility/useKeyboardNavigation.ts +292 -0
- 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
|
+
}
|