import { useCallback, useRef } from 'react';
/**
* Options for the useKeyboardNavigation hook.
*/
export interface UseKeyboardNavigationOptions {
/** Refs to the navigable items */
itemRefs: React.RefObject<(HTMLElement | null)[]>;
/** Currently focused item index */
focusedIndex: number;
/** Callback to update the focused index */
setFocusedIndex: (index: number) => void;
/** Total number of items */
itemCount: number;
/** Navigation orientation - determines which arrow keys are used */
orientation?: 'horizontal' | 'vertical' | 'both';
/** Whether navigation wraps from last to first and vice versa */
wrap?: boolean;
/** Callback when an item is selected (Enter/Space pressed) */
onSelect?: (index: number) => void;
/** Callback when Escape is pressed */
onEscape?: () => void;
/** Function to get the text label of an item for type-ahead search */
getItemLabel?: (index: number) => string;
/** Whether navigation is disabled */
disabled?: boolean;
/** Whether to auto-select on navigation (like tabs with automatic activation) */
autoSelect?: boolean;
}
/**
* Return type for the useKeyboardNavigation hook.
*/
export interface UseKeyboardNavigationReturn {
/** Keyboard event handler to attach to the container or items */
handleKeyDown: (event: React.KeyboardEvent) => void;
/** Programmatically focus an item by index */
focusItem: (index: number) => void;
/** Navigate to the next item */
navigateNext: () => void;
/** Navigate to the previous item */
navigatePrevious: () => void;
/** Navigate to the first item */
navigateFirst: () => void;
/** Navigate to the last item */
navigateLast: () => void;
}
/**
* Hook for implementing WCAG-compliant keyboard navigation in lists, menus, tabs, etc.
*
* Features:
* - Arrow key navigation (horizontal, vertical, or both)
* - Home/End for first/last item
* - Enter/Space for selection
* - Escape for closing/canceling
* - Type-ahead search for quick navigation
* - Optional wrap-around navigation
* - Auto-select on navigation (for tabs)
*
* @example
* ```tsx
* const itemRefs = useRef<(HTMLElement | null)[]>([]);
* const [focusedIndex, setFocusedIndex] = useState(0);
*
* const { handleKeyDown, focusItem } = useKeyboardNavigation({
* itemRefs,
* focusedIndex,
* setFocusedIndex,
* itemCount: items.length,
* orientation: 'vertical',
* onSelect: (index) => handleItemSelect(items[index]),
* });
*
* return (
*
* {items.map((item, i) => (
*
{ itemRefs.current[i] = el; }}
* role="option"
* tabIndex={i === focusedIndex ? 0 : -1}
* >
* {item}
*
* ))}
*
* );
* ```
*/
export function useKeyboardNavigation({
itemRefs,
focusedIndex,
setFocusedIndex,
itemCount,
orientation = 'vertical',
wrap = true,
onSelect,
onEscape,
getItemLabel,
disabled = false,
autoSelect = false,
}: UseKeyboardNavigationOptions): UseKeyboardNavigationReturn {
// Buffer for type-ahead search
const searchBuffer = useRef('');
const searchTimeout = useRef | null>(null);
/**
* Focus a specific item by index.
*/
const focusItem = useCallback(
(index: number) => {
const items = itemRefs.current;
if (items && items[index]) {
items[index]?.focus();
setFocusedIndex(index);
if (autoSelect) {
onSelect?.(index);
}
}
},
[itemRefs, setFocusedIndex, autoSelect, onSelect]
);
/**
* Navigate to the next item.
*/
const navigateNext = useCallback(() => {
if (disabled || itemCount === 0) return;
const nextIndex = focusedIndex + 1;
if (nextIndex >= itemCount) {
if (wrap) focusItem(0);
} else {
focusItem(nextIndex);
}
}, [disabled, itemCount, focusedIndex, wrap, focusItem]);
/**
* Navigate to the previous item.
*/
const navigatePrevious = useCallback(() => {
if (disabled || itemCount === 0) return;
const prevIndex = focusedIndex - 1;
if (prevIndex < 0) {
if (wrap) focusItem(itemCount - 1);
} else {
focusItem(prevIndex);
}
}, [disabled, itemCount, focusedIndex, wrap, focusItem]);
/**
* Navigate to the first item.
*/
const navigateFirst = useCallback(() => {
if (disabled || itemCount === 0) return;
focusItem(0);
}, [disabled, itemCount, focusItem]);
/**
* Navigate to the last item.
*/
const navigateLast = useCallback(() => {
if (disabled || itemCount === 0) return;
focusItem(itemCount - 1);
}, [disabled, itemCount, focusItem]);
/**
* Handle type-ahead search - jump to item starting with typed character(s).
*/
const handleTypeAhead = useCallback(
(char: string) => {
if (!getItemLabel || disabled) return;
// Clear existing timeout
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
// Append character to search buffer
searchBuffer.current += char.toLowerCase();
// Find matching item starting from current index + 1
for (let i = 0; i < itemCount; i++) {
const index = (focusedIndex + i + 1) % itemCount;
const label = getItemLabel(index).toLowerCase();
if (label.startsWith(searchBuffer.current)) {
focusItem(index);
break;
}
}
// Clear buffer after 500ms of inactivity (standard type-ahead delay)
searchTimeout.current = setTimeout(() => {
searchBuffer.current = '';
}, 500);
},
[getItemLabel, disabled, itemCount, focusedIndex, focusItem]
);
/**
* Main keyboard event handler.
*/
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (disabled) return;
const { key } = event;
// Handle arrow keys based on orientation
if (orientation === 'vertical' || orientation === 'both') {
if (key === 'ArrowDown') {
event.preventDefault();
navigateNext();
return;
}
if (key === 'ArrowUp') {
event.preventDefault();
navigatePrevious();
return;
}
}
if (orientation === 'horizontal' || orientation === 'both') {
if (key === 'ArrowRight') {
event.preventDefault();
navigateNext();
return;
}
if (key === 'ArrowLeft') {
event.preventDefault();
navigatePrevious();
return;
}
}
// Common navigation keys
switch (key) {
case 'Home':
event.preventDefault();
navigateFirst();
break;
case 'End':
event.preventDefault();
navigateLast();
break;
case 'Enter':
case ' ':
// Space should only select if we're not in a text input
if (key === ' ' && event.target instanceof HTMLInputElement) {
return; // Let the input handle the space
}
event.preventDefault();
onSelect?.(focusedIndex);
break;
case 'Escape':
event.preventDefault();
onEscape?.();
break;
default:
// Type-ahead for printable characters
if (key.length === 1 && /[a-zA-Z0-9]/.test(key)) {
handleTypeAhead(key);
}
}
},
[
disabled,
orientation,
navigateNext,
navigatePrevious,
navigateFirst,
navigateLast,
onSelect,
onEscape,
focusedIndex,
handleTypeAhead,
]
);
return {
handleKeyDown,
focusItem,
navigateNext,
navigatePrevious,
navigateFirst,
navigateLast,
};
}