refactor(cli): Extract reusable BaseSelectionList component and modernize RadioButtonSelect tests (#9021)

This commit is contained in:
Abhi
2025-09-22 11:14:41 -04:00
committed by GitHub
parent edd988be60
commit 81d03cb524
10 changed files with 2066 additions and 399 deletions
@@ -0,0 +1,809 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
useSelectionList,
type SelectionListItem,
} from './useSelectionList.js';
import { useKeypress } from './useKeypress.js';
import type { KeypressHandler, Key } from '../contexts/KeypressContext.js';
type UseKeypressMockOptions = { isActive: boolean };
vi.mock('./useKeypress.js');
let activeKeypressHandler: KeypressHandler | null = null;
describe('useSelectionList', () => {
const mockOnSelect = vi.fn();
const mockOnHighlight = vi.fn();
const items: Array<SelectionListItem<string>> = [
{ value: 'A' },
{ value: 'B', disabled: true },
{ value: 'C' },
{ value: 'D' },
];
beforeEach(() => {
activeKeypressHandler = null;
vi.mocked(useKeypress).mockImplementation(
(handler: KeypressHandler, options?: UseKeypressMockOptions) => {
if (options?.isActive) {
activeKeypressHandler = handler;
} else {
activeKeypressHandler = null;
}
},
);
mockOnSelect.mockClear();
mockOnHighlight.mockClear();
});
const pressKey = (name: string, sequence: string = name) => {
act(() => {
if (activeKeypressHandler) {
const key: Key = {
name,
sequence,
ctrl: false,
meta: false,
shift: false,
paste: false,
};
activeKeypressHandler(key);
} else {
throw new Error(
`Test attempted to press key (${name}) but the keypress handler is not active. Ensure the hook is focused (isFocused=true) and the list is not empty.`,
);
}
});
};
describe('Initialization', () => {
it('should initialize with the default index (0) if enabled', () => {
const { result } = renderHook(() =>
useSelectionList({ items, onSelect: mockOnSelect }),
);
expect(result.current.activeIndex).toBe(0);
});
it('should initialize with the provided initialIndex if enabled', () => {
const { result } = renderHook(() =>
useSelectionList({
items,
initialIndex: 2,
onSelect: mockOnSelect,
}),
);
expect(result.current.activeIndex).toBe(2);
});
it('should handle an empty list gracefully', () => {
const { result } = renderHook(() =>
useSelectionList({ items: [], onSelect: mockOnSelect }),
);
expect(result.current.activeIndex).toBe(0);
});
it('should find the next enabled item (downwards) if initialIndex is disabled', () => {
const { result } = renderHook(() =>
useSelectionList({
items,
initialIndex: 1,
onSelect: mockOnSelect,
}),
);
expect(result.current.activeIndex).toBe(2);
});
it('should wrap around to find the next enabled item if initialIndex is disabled', () => {
const wrappingItems = [
{ value: 'A' },
{ value: 'B', disabled: true },
{ value: 'C', disabled: true },
];
const { result } = renderHook(() =>
useSelectionList({
items: wrappingItems,
initialIndex: 2,
onSelect: mockOnSelect,
}),
);
expect(result.current.activeIndex).toBe(0);
});
it('should default to 0 if initialIndex is out of bounds', () => {
const { result } = renderHook(() =>
useSelectionList({
items,
initialIndex: 10,
onSelect: mockOnSelect,
}),
);
expect(result.current.activeIndex).toBe(0);
const { result: resultNeg } = renderHook(() =>
useSelectionList({
items,
initialIndex: -1,
onSelect: mockOnSelect,
}),
);
expect(resultNeg.current.activeIndex).toBe(0);
});
it('should stick to the initial index if all items are disabled', () => {
const allDisabled = [
{ value: 'A', disabled: true },
{ value: 'B', disabled: true },
];
const { result } = renderHook(() =>
useSelectionList({
items: allDisabled,
initialIndex: 1,
onSelect: mockOnSelect,
}),
);
expect(result.current.activeIndex).toBe(1);
});
});
describe('Keyboard Navigation (Up/Down/J/K)', () => {
it('should move down with "j" and "down" keys, skipping disabled items', () => {
const { result } = renderHook(() =>
useSelectionList({ items, onSelect: mockOnSelect }),
);
expect(result.current.activeIndex).toBe(0);
pressKey('j');
expect(result.current.activeIndex).toBe(2);
pressKey('down');
expect(result.current.activeIndex).toBe(3);
});
it('should move up with "k" and "up" keys, skipping disabled items', () => {
const { result } = renderHook(() =>
useSelectionList({ items, initialIndex: 3, onSelect: mockOnSelect }),
);
expect(result.current.activeIndex).toBe(3);
pressKey('k');
expect(result.current.activeIndex).toBe(2);
pressKey('up');
expect(result.current.activeIndex).toBe(0);
});
it('should wrap navigation correctly', () => {
const { result } = renderHook(() =>
useSelectionList({
items,
initialIndex: items.length - 1,
onSelect: mockOnSelect,
}),
);
expect(result.current.activeIndex).toBe(3);
pressKey('down');
expect(result.current.activeIndex).toBe(0);
pressKey('up');
expect(result.current.activeIndex).toBe(3);
});
it('should call onHighlight when index changes', () => {
renderHook(() =>
useSelectionList({
items,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
}),
);
pressKey('down');
expect(mockOnHighlight).toHaveBeenCalledTimes(1);
expect(mockOnHighlight).toHaveBeenCalledWith('C');
});
it('should not move or call onHighlight if navigation results in the same index (e.g., single item)', () => {
const singleItem = [{ value: 'A' }];
const { result } = renderHook(() =>
useSelectionList({
items: singleItem,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
}),
);
pressKey('down');
expect(result.current.activeIndex).toBe(0);
expect(mockOnHighlight).not.toHaveBeenCalled();
});
it('should not move or call onHighlight if all items are disabled', () => {
const allDisabled = [
{ value: 'A', disabled: true },
{ value: 'B', disabled: true },
];
const { result } = renderHook(() =>
useSelectionList({
items: allDisabled,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
}),
);
const initialIndex = result.current.activeIndex;
pressKey('down');
expect(result.current.activeIndex).toBe(initialIndex);
expect(mockOnHighlight).not.toHaveBeenCalled();
});
});
describe('Selection (Enter)', () => {
it('should call onSelect when "return" is pressed on enabled item', () => {
renderHook(() =>
useSelectionList({
items,
initialIndex: 2,
onSelect: mockOnSelect,
}),
);
pressKey('return');
expect(mockOnSelect).toHaveBeenCalledTimes(1);
expect(mockOnSelect).toHaveBeenCalledWith('C');
});
it('should not call onSelect if the active item is disabled', () => {
const { result } = renderHook(() =>
useSelectionList({
items,
onSelect: mockOnSelect,
}),
);
act(() => result.current.setActiveIndex(1));
pressKey('return');
expect(mockOnSelect).not.toHaveBeenCalled();
});
});
describe('Keyboard Navigation Robustness (Rapid Input)', () => {
it('should handle rapid navigation and selection robustly (avoiding stale state)', () => {
const { result } = renderHook(() =>
useSelectionList({
items, // A, B(disabled), C, D. Initial index 0 (A).
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
}),
);
// Simulate rapid inputs with separate act blocks to allow effects to run
if (!activeKeypressHandler) throw new Error('Handler not active');
const handler = activeKeypressHandler;
const press = (name: string) => {
const key: Key = {
name,
sequence: name,
ctrl: false,
meta: false,
shift: false,
paste: false,
};
handler(key);
};
// 1. Press Down. Should move 0 (A) -> 2 (C).
act(() => {
press('down');
});
// 2. Press Down again. Should move 2 (C) -> 3 (D).
act(() => {
press('down');
});
// 3. Press Enter. Should select D.
act(() => {
press('return');
});
expect(result.current.activeIndex).toBe(3);
expect(mockOnHighlight).toHaveBeenCalledTimes(2);
expect(mockOnHighlight).toHaveBeenNthCalledWith(1, 'C');
expect(mockOnHighlight).toHaveBeenNthCalledWith(2, 'D');
expect(mockOnSelect).toHaveBeenCalledTimes(1);
expect(mockOnSelect).toHaveBeenCalledWith('D');
expect(mockOnSelect).not.toHaveBeenCalledWith('A');
});
it('should handle ultra-rapid input (multiple presses in single act) without stale state', () => {
const { result } = renderHook(() =>
useSelectionList({
items, // A, B(disabled), C, D. Initial index 0 (A).
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
}),
);
// Simulate ultra-rapid inputs where all keypresses happen faster than React can re-render
act(() => {
if (!activeKeypressHandler) throw new Error('Handler not active');
const handler = activeKeypressHandler;
const press = (name: string) => {
const key: Key = {
name,
sequence: name,
ctrl: false,
meta: false,
shift: false,
paste: false,
};
handler(key);
};
// All presses happen in same render cycle - React batches the state updates
press('down'); // Should move 0 (A) -> 2 (C)
press('down'); // Should move 2 (C) -> 3 (D)
press('return'); // Should select D
});
expect(result.current.activeIndex).toBe(3);
expect(mockOnHighlight).toHaveBeenCalledWith('D');
expect(mockOnSelect).toHaveBeenCalledTimes(1);
expect(mockOnSelect).toHaveBeenCalledWith('D');
});
});
describe('Focus Management (isFocused)', () => {
it('should activate the keypress handler when focused (default) and items exist', () => {
const { result } = renderHook(() =>
useSelectionList({ items, onSelect: mockOnSelect }),
);
expect(activeKeypressHandler).not.toBeNull();
pressKey('down');
expect(result.current.activeIndex).toBe(2);
});
it('should not activate the keypress handler when isFocused is false', () => {
renderHook(() =>
useSelectionList({ items, onSelect: mockOnSelect, isFocused: false }),
);
expect(activeKeypressHandler).toBeNull();
expect(() => pressKey('down')).toThrow(/keypress handler is not active/);
});
it('should not activate the keypress handler when items list is empty', () => {
renderHook(() =>
useSelectionList({
items: [],
onSelect: mockOnSelect,
isFocused: true,
}),
);
expect(activeKeypressHandler).toBeNull();
expect(() => pressKey('down')).toThrow(/keypress handler is not active/);
});
it('should activate/deactivate when isFocused prop changes', () => {
const { result, rerender } = renderHook(
(props: { isFocused: boolean }) =>
useSelectionList({ items, onSelect: mockOnSelect, ...props }),
{ initialProps: { isFocused: false } },
);
expect(activeKeypressHandler).toBeNull();
rerender({ isFocused: true });
expect(activeKeypressHandler).not.toBeNull();
pressKey('down');
expect(result.current.activeIndex).toBe(2);
rerender({ isFocused: false });
expect(activeKeypressHandler).toBeNull();
expect(() => pressKey('down')).toThrow(/keypress handler is not active/);
});
});
describe('Numeric Quick Selection (showNumbers=true)', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const shortList = items;
const longList: Array<SelectionListItem<string>> = Array.from(
{ length: 15 },
(_, i) => ({ value: `Item ${i + 1}` }),
);
const pressNumber = (num: string) => pressKey(num, num);
it('should not respond to numbers if showNumbers is false (default)', () => {
const { result } = renderHook(() =>
useSelectionList({ items: shortList, onSelect: mockOnSelect }),
);
pressNumber('1');
expect(result.current.activeIndex).toBe(0);
expect(mockOnSelect).not.toHaveBeenCalled();
});
it('should select item immediately if the number cannot be extended (unambiguous)', () => {
const { result } = renderHook(() =>
useSelectionList({
items: shortList,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
showNumbers: true,
}),
);
pressNumber('3');
expect(result.current.activeIndex).toBe(2);
expect(mockOnHighlight).toHaveBeenCalledWith('C');
expect(mockOnSelect).toHaveBeenCalledTimes(1);
expect(mockOnSelect).toHaveBeenCalledWith('C');
expect(vi.getTimerCount()).toBe(0);
});
it('should highlight and wait for timeout if the number can be extended (ambiguous)', () => {
const { result } = renderHook(() =>
useSelectionList({
items: longList,
initialIndex: 1, // Start at index 1 so pressing "1" (index 0) causes a change
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
showNumbers: true,
}),
);
pressNumber('1');
expect(result.current.activeIndex).toBe(0);
expect(mockOnHighlight).toHaveBeenCalledWith('Item 1');
expect(mockOnSelect).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(1);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(mockOnSelect).toHaveBeenCalledTimes(1);
expect(mockOnSelect).toHaveBeenCalledWith('Item 1');
});
it('should handle multi-digit input correctly', () => {
const { result } = renderHook(() =>
useSelectionList({
items: longList,
onSelect: mockOnSelect,
showNumbers: true,
}),
);
pressNumber('1');
expect(mockOnSelect).not.toHaveBeenCalled();
pressNumber('2');
expect(result.current.activeIndex).toBe(11);
expect(mockOnSelect).toHaveBeenCalledTimes(1);
expect(mockOnSelect).toHaveBeenCalledWith('Item 12');
});
it('should reset buffer if input becomes invalid (out of bounds)', () => {
const { result } = renderHook(() =>
useSelectionList({
items: shortList,
onSelect: mockOnSelect,
showNumbers: true,
}),
);
pressNumber('5');
expect(result.current.activeIndex).toBe(0);
expect(mockOnSelect).not.toHaveBeenCalled();
pressNumber('3');
expect(result.current.activeIndex).toBe(2);
expect(mockOnSelect).toHaveBeenCalledWith('C');
});
it('should allow "0" as subsequent digit, but ignore as first digit', () => {
const { result } = renderHook(() =>
useSelectionList({
items: longList,
onSelect: mockOnSelect,
showNumbers: true,
}),
);
pressNumber('0');
expect(result.current.activeIndex).toBe(0);
expect(mockOnSelect).not.toHaveBeenCalled();
// Timer should be running to clear the '0' input buffer
expect(vi.getTimerCount()).toBe(1);
// Press '1', then '0' (Item 10, index 9)
pressNumber('1');
pressNumber('0');
expect(result.current.activeIndex).toBe(9);
expect(mockOnSelect).toHaveBeenCalledWith('Item 10');
});
it('should clear the initial "0" input after timeout', () => {
renderHook(() =>
useSelectionList({
items: longList,
onSelect: mockOnSelect,
showNumbers: true,
}),
);
pressNumber('0');
act(() => vi.advanceTimersByTime(1000)); // Timeout the '0' input
pressNumber('1');
expect(mockOnSelect).not.toHaveBeenCalled(); // Should be waiting for second digit
act(() => vi.advanceTimersByTime(1000)); // Timeout '1'
expect(mockOnSelect).toHaveBeenCalledWith('Item 1');
});
it('should highlight but not select a disabled item (immediate selection case)', () => {
const { result } = renderHook(() =>
useSelectionList({
items: shortList, // B (index 1, number 2) is disabled
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
showNumbers: true,
}),
);
pressNumber('2');
expect(result.current.activeIndex).toBe(1);
expect(mockOnHighlight).toHaveBeenCalledWith('B');
// Should not select immediately, even though 20 > 4
expect(mockOnSelect).not.toHaveBeenCalled();
});
it('should highlight but not select a disabled item (timeout case)', () => {
// Create a list where the ambiguous prefix points to a disabled item
const disabledAmbiguousList = [
{ value: 'Item 1 Disabled', disabled: true },
...longList.slice(1),
];
const { result } = renderHook(() =>
useSelectionList({
items: disabledAmbiguousList,
onSelect: mockOnSelect,
showNumbers: true,
}),
);
pressNumber('1');
expect(result.current.activeIndex).toBe(0);
expect(vi.getTimerCount()).toBe(1);
act(() => {
vi.advanceTimersByTime(1000);
});
// Should not select after timeout
expect(mockOnSelect).not.toHaveBeenCalled();
});
it('should clear the number buffer if a non-numeric key (e.g., navigation) is pressed', () => {
const { result } = renderHook(() =>
useSelectionList({
items: longList,
onSelect: mockOnSelect,
showNumbers: true,
}),
);
pressNumber('1');
expect(vi.getTimerCount()).toBe(1);
pressKey('down');
expect(result.current.activeIndex).toBe(1);
expect(vi.getTimerCount()).toBe(0);
pressNumber('3');
// Should select '3', not '13'
expect(result.current.activeIndex).toBe(2);
});
it('should clear the number buffer if "return" is pressed', () => {
renderHook(() =>
useSelectionList({
items: longList,
onSelect: mockOnSelect,
showNumbers: true,
}),
);
pressNumber('1');
pressKey('return');
expect(mockOnSelect).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(mockOnSelect).toHaveBeenCalledTimes(1);
});
});
describe('Reactivity (Dynamic Updates)', () => {
it('should update activeIndex when initialIndex prop changes', () => {
const { result, rerender } = renderHook(
({ initialIndex }: { initialIndex: number }) =>
useSelectionList({
items,
onSelect: mockOnSelect,
initialIndex,
}),
{ initialProps: { initialIndex: 0 } },
);
rerender({ initialIndex: 2 });
expect(result.current.activeIndex).toBe(2);
});
it('should validate index when initialIndex prop changes to a disabled item', () => {
const { result, rerender } = renderHook(
({ initialIndex }: { initialIndex: number }) =>
useSelectionList({
items,
onSelect: mockOnSelect,
initialIndex,
}),
{ initialProps: { initialIndex: 0 } },
);
rerender({ initialIndex: 1 });
expect(result.current.activeIndex).toBe(2);
});
it('should adjust activeIndex if items change and the initialIndex is now out of bounds', () => {
const { result, rerender } = renderHook(
({ items: testItems }: { items: Array<SelectionListItem<string>> }) =>
useSelectionList({
onSelect: mockOnSelect,
initialIndex: 3,
items: testItems,
}),
{ initialProps: { items } },
);
expect(result.current.activeIndex).toBe(3);
const shorterItems = [{ value: 'X' }, { value: 'Y' }];
rerender({ items: shorterItems }); // Length 2
// The useEffect syncs based on the initialIndex (3) which is now out of bounds. It defaults to 0.
expect(result.current.activeIndex).toBe(0);
});
it('should adjust activeIndex if items change and the initialIndex becomes disabled', () => {
const initialItems = [{ value: 'A' }, { value: 'B' }, { value: 'C' }];
const { result, rerender } = renderHook(
({ items: testItems }: { items: Array<SelectionListItem<string>> }) =>
useSelectionList({
onSelect: mockOnSelect,
initialIndex: 1,
items: testItems,
}),
{ initialProps: { items: initialItems } },
);
expect(result.current.activeIndex).toBe(1);
const newItems = [
{ value: 'A' },
{ value: 'B', disabled: true },
{ value: 'C' },
];
rerender({ items: newItems });
expect(result.current.activeIndex).toBe(2);
});
it('should reset to 0 if items change to an empty list', () => {
const { result, rerender } = renderHook(
({ items: testItems }: { items: Array<SelectionListItem<string>> }) =>
useSelectionList({
onSelect: mockOnSelect,
initialIndex: 2,
items: testItems,
}),
{ initialProps: { items } },
);
rerender({ items: [] });
expect(result.current.activeIndex).toBe(0);
});
});
describe('Manual Control', () => {
it('should allow manual setting of active index via setActiveIndex', () => {
const { result } = renderHook(() =>
useSelectionList({ items, onSelect: mockOnSelect }),
);
act(() => {
result.current.setActiveIndex(3);
});
expect(result.current.activeIndex).toBe(3);
act(() => {
result.current.setActiveIndex(1);
});
expect(result.current.activeIndex).toBe(1);
});
});
describe('Cleanup', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should clear timeout on unmount when timer is active', () => {
const longList: Array<SelectionListItem<string>> = Array.from(
{ length: 15 },
(_, i) => ({ value: `Item ${i + 1}` }),
);
const { unmount } = renderHook(() =>
useSelectionList({
items: longList,
onSelect: mockOnSelect,
showNumbers: true,
}),
);
pressKey('1', '1');
expect(vi.getTimerCount()).toBe(1);
act(() => {
vi.advanceTimersByTime(500);
});
expect(mockOnSelect).not.toHaveBeenCalled();
unmount();
expect(vi.getTimerCount()).toBe(0);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(mockOnSelect).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,343 @@
/**
* @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<T> {
value: T;
disabled?: boolean;
}
export interface UseSelectionListOptions<T> {
items: Array<SelectionListItem<T>>;
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<T> =
| {
type: 'SET_ACTIVE_INDEX';
payload: {
index: number;
items: Array<SelectionListItem<T>>;
};
}
| {
type: 'MOVE_UP';
payload: {
items: Array<SelectionListItem<T>>;
};
}
| {
type: 'MOVE_DOWN';
payload: {
items: Array<SelectionListItem<T>>;
};
}
| {
type: 'SELECT_CURRENT';
payload: {
items: Array<SelectionListItem<T>>;
};
}
| {
type: 'INITIALIZE';
payload: { initialIndex: number; items: Array<SelectionListItem<T>> };
}
| {
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 = <T>(
currentIndex: number,
direction: 'up' | 'down',
items: Array<SelectionListItem<T>>,
): 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<T>(
state: SelectionListState,
action: SelectionListAction<T>,
): 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<T>({
items,
initialIndex = 0,
onSelect,
onHighlight,
isFocused = true,
showNumbers = false,
}: UseSelectionListOptions<T>): UseSelectionListResult {
const [state, dispatch] = useReducer(selectionListReducer<T>, {
activeIndex: initialIndex,
pendingHighlight: false,
pendingSelect: false,
});
const numberInputRef = useRef('');
const numberInputTimer = useRef<NodeJS.Timeout | null>(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,
};
}