/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useReducer, useRef, useEffect } from 'react'; import { useKeypress } from './useKeypress.js'; export interface SelectionListItem { value: T; disabled?: boolean; } export interface UseSelectionListOptions { items: Array>; initialIndex?: number; onSelect: (value: T) => void; onHighlight?: (value: T) => void; isFocused?: boolean; showNumbers?: boolean; } export interface UseSelectionListResult { activeIndex: number; setActiveIndex: (index: number) => void; } interface SelectionListState { activeIndex: number; pendingHighlight: boolean; pendingSelect: boolean; } type SelectionListAction = | { type: 'SET_ACTIVE_INDEX'; payload: { index: number; items: Array>; }; } | { type: 'MOVE_UP'; payload: { items: Array>; }; } | { type: 'MOVE_DOWN'; payload: { items: Array>; }; } | { type: 'SELECT_CURRENT'; payload: { items: Array>; }; } | { type: 'INITIALIZE'; payload: { initialIndex: number; items: Array> }; } | { type: 'CLEAR_PENDING_FLAGS'; }; const NUMBER_INPUT_TIMEOUT_MS = 1000; /** * Helper function to find the next enabled index in a given direction, supporting wrapping. */ const findNextValidIndex = ( currentIndex: number, direction: 'up' | 'down', items: Array>, ): number => { const len = items.length; if (len === 0) return currentIndex; let nextIndex = currentIndex; const step = direction === 'down' ? 1 : -1; for (let i = 0; i < len; i++) { // Calculate the next index, wrapping around if necessary. // We add `len` before the modulo to ensure a positive result in JS for negative steps. nextIndex = (nextIndex + step + len) % len; if (!items[nextIndex]?.disabled) { return nextIndex; } } // If all items are disabled, return the original index return currentIndex; }; function selectionListReducer( state: SelectionListState, action: SelectionListAction, ): SelectionListState { switch (action.type) { case 'SET_ACTIVE_INDEX': { const { index, items } = action.payload; // Only update if index actually changed and is valid if (index === state.activeIndex) { return state; } if (index >= 0 && index < items.length) { return { ...state, activeIndex: index, pendingHighlight: true }; } return state; } case 'MOVE_UP': { const { items } = action.payload; const newIndex = findNextValidIndex(state.activeIndex, 'up', items); if (newIndex !== state.activeIndex) { return { ...state, activeIndex: newIndex, pendingHighlight: true }; } return state; } case 'MOVE_DOWN': { const { items } = action.payload; const newIndex = findNextValidIndex(state.activeIndex, 'down', items); if (newIndex !== state.activeIndex) { return { ...state, activeIndex: newIndex, pendingHighlight: true }; } return state; } case 'SELECT_CURRENT': { return { ...state, pendingSelect: true }; } case 'INITIALIZE': { const { initialIndex, items } = action.payload; if (items.length === 0) { const newIndex = 0; return newIndex === state.activeIndex ? state : { ...state, activeIndex: newIndex }; } let targetIndex = initialIndex; if (targetIndex < 0 || targetIndex >= items.length) { targetIndex = 0; } if (items[targetIndex]?.disabled) { const nextValid = findNextValidIndex(targetIndex, 'down', items); targetIndex = nextValid; } // Only return new state if activeIndex actually changed // Don't set pendingHighlight on initialization return targetIndex === state.activeIndex ? state : { ...state, activeIndex: targetIndex, pendingHighlight: false }; } case 'CLEAR_PENDING_FLAGS': { return { ...state, pendingHighlight: false, pendingSelect: false, }; } default: { const exhaustiveCheck: never = action; console.error(`Unknown selection list action: ${exhaustiveCheck}`); return state; } } } /** * A headless hook that provides keyboard navigation and selection logic * for list-based selection components like radio buttons and menus. * * Features: * - Keyboard navigation with j/k and arrow keys * - Selection with Enter key * - Numeric quick selection (when showNumbers is true) * - Handles disabled items (skips them during navigation) * - Wrapping navigation (last to first, first to last) */ export function useSelectionList({ items, initialIndex = 0, onSelect, onHighlight, isFocused = true, showNumbers = false, }: UseSelectionListOptions): UseSelectionListResult { const [state, dispatch] = useReducer(selectionListReducer, { activeIndex: initialIndex, pendingHighlight: false, pendingSelect: false, }); const numberInputRef = useRef(''); const numberInputTimer = useRef(null); // Initialize/synchronize state when initialIndex or items change useEffect(() => { dispatch({ type: 'INITIALIZE', payload: { initialIndex, items } }); }, [initialIndex, items]); // Handle side effects based on state changes useEffect(() => { let needsClear = false; if (state.pendingHighlight && items[state.activeIndex]) { onHighlight?.(items[state.activeIndex]!.value); needsClear = true; } if (state.pendingSelect && items[state.activeIndex]) { const currentItem = items[state.activeIndex]; if (currentItem && !currentItem.disabled) { onSelect(currentItem.value); } needsClear = true; } if (needsClear) { dispatch({ type: 'CLEAR_PENDING_FLAGS' }); } }, [ state.pendingHighlight, state.pendingSelect, state.activeIndex, items, onHighlight, onSelect, ]); useEffect( () => () => { if (numberInputTimer.current) { clearTimeout(numberInputTimer.current); } }, [], ); useKeypress( (key) => { const { sequence, name } = key; const isNumeric = showNumbers && /^[0-9]$/.test(sequence); // Clear number input buffer on non-numeric key press if (!isNumeric && numberInputTimer.current) { clearTimeout(numberInputTimer.current); numberInputRef.current = ''; } if (name === 'k' || name === 'up') { dispatch({ type: 'MOVE_UP', payload: { items } }); return; } if (name === 'j' || name === 'down') { dispatch({ type: 'MOVE_DOWN', payload: { items } }); return; } if (name === 'return') { dispatch({ type: 'SELECT_CURRENT', payload: { items } }); return; } // Handle numeric input for quick selection if (isNumeric) { if (numberInputTimer.current) { clearTimeout(numberInputTimer.current); } const newNumberInput = numberInputRef.current + sequence; numberInputRef.current = newNumberInput; const targetIndex = Number.parseInt(newNumberInput, 10) - 1; // Single '0' is invalid (1-indexed) if (newNumberInput === '0') { numberInputTimer.current = setTimeout(() => { numberInputRef.current = ''; }, NUMBER_INPUT_TIMEOUT_MS); return; } if (targetIndex >= 0 && targetIndex < items.length) { dispatch({ type: 'SET_ACTIVE_INDEX', payload: { index: targetIndex, items }, }); // If the number can't be a prefix for another valid number, select immediately const potentialNextNumber = Number.parseInt(newNumberInput + '0', 10); if (potentialNextNumber > items.length) { dispatch({ type: 'SELECT_CURRENT', payload: { items }, }); numberInputRef.current = ''; } else { // Otherwise wait for more input or timeout numberInputTimer.current = setTimeout(() => { dispatch({ type: 'SELECT_CURRENT', payload: { items }, }); numberInputRef.current = ''; }, NUMBER_INPUT_TIMEOUT_MS); } } else { // Number is out of bounds numberInputRef.current = ''; } } }, { isActive: !!(isFocused && items.length > 0) }, ); const setActiveIndex = (index: number) => { dispatch({ type: 'SET_ACTIVE_INDEX', payload: { index, items }, }); }; return { activeIndex: state.activeIndex, setActiveIndex, }; }