/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React, { useEffect, useState, useRef } from 'react'; import { Text, Box } from 'ink'; import { theme } from '../../semantic-colors.js'; import { useKeypress } from '../../hooks/useKeypress.js'; /** * Represents a single option for the RadioButtonSelect. * Requires a label for display and a value to be returned on selection. */ export interface RadioSelectItem { label: string; value: T; disabled?: boolean; themeNameDisplay?: string; themeTypeDisplay?: string; } /** * Props for the RadioButtonSelect component. * @template T The type of the value associated with each radio item. */ export interface RadioButtonSelectProps { /** An array of items to display as radio options. */ items: Array>; /** The initial index selected */ initialIndex?: number; /** Function called when an item is selected. Receives the `value` of the selected item. */ onSelect: (value: T) => void; /** Function called when an item is highlighted. Receives the `value` of the selected item. */ onHighlight?: (value: T) => void; /** Whether this select input is currently focused and should respond to input. */ isFocused?: boolean; /** Whether to show the scroll arrows. */ showScrollArrows?: boolean; /** The maximum number of items to show at once. */ maxItemsToShow?: number; /** Whether to show numbers next to items. */ showNumbers?: boolean; } /** * A custom component that displays a list of items with radio buttons, * supporting scrolling and keyboard navigation. * * @template T The type of the value associated with each radio item. */ export function RadioButtonSelect({ items, initialIndex = 0, onSelect, onHighlight, isFocused = true, showScrollArrows = false, maxItemsToShow = 10, showNumbers = true, }: RadioButtonSelectProps): React.JSX.Element { const [activeIndex, setActiveIndex] = useState(initialIndex); const [scrollOffset, setScrollOffset] = useState(0); const [numberInput, setNumberInput] = useState(''); const numberInputTimer = useRef(null); useEffect(() => { const newScrollOffset = Math.max( 0, Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow), ); if (activeIndex < scrollOffset) { setScrollOffset(activeIndex); } else if (activeIndex >= scrollOffset + maxItemsToShow) { setScrollOffset(newScrollOffset); } }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); useEffect( () => () => { if (numberInputTimer.current) { clearTimeout(numberInputTimer.current); } }, [], ); useKeypress( (key) => { const { sequence, name } = key; const isNumeric = showNumbers && /^[0-9]$/.test(sequence); // Any key press that is not a digit should clear the number input buffer. if (!isNumeric && numberInputTimer.current) { clearTimeout(numberInputTimer.current); setNumberInput(''); } if (name === 'k' || name === 'up') { const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; setActiveIndex(newIndex); onHighlight?.(items[newIndex]!.value); return; } if (name === 'j' || name === 'down') { const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; setActiveIndex(newIndex); onHighlight?.(items[newIndex]!.value); return; } if (name === 'return') { onSelect(items[activeIndex]!.value); return; } // Handle numeric input for selection. if (isNumeric) { if (numberInputTimer.current) { clearTimeout(numberInputTimer.current); } const newNumberInput = numberInput + sequence; setNumberInput(newNumberInput); const targetIndex = Number.parseInt(newNumberInput, 10) - 1; // A single '0' is not a valid selection since items are 1-indexed. if (newNumberInput === '0') { numberInputTimer.current = setTimeout(() => setNumberInput(''), 350); return; } if (targetIndex >= 0 && targetIndex < items.length) { const targetItem = items[targetIndex]!; setActiveIndex(targetIndex); onHighlight?.(targetItem.value); // If the typed number can't be a prefix for another valid number, // select it immediately. Otherwise, wait for more input. const potentialNextNumber = Number.parseInt(newNumberInput + '0', 10); if (potentialNextNumber > items.length) { onSelect(targetItem.value); setNumberInput(''); } else { numberInputTimer.current = setTimeout(() => { onSelect(targetItem.value); setNumberInput(''); }, 350); // Debounce time for multi-digit input. } } else { // The typed number is out of bounds, clear the buffer setNumberInput(''); } } }, { isActive: !!(isFocused && items.length > 0) }, ); const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); return ( {showScrollArrows && ( 0 ? theme.text.primary : theme.text.secondary} > ▲ )} {visibleItems.map((item, index) => { const itemIndex = scrollOffset + index; const isSelected = activeIndex === itemIndex; let textColor = theme.text.primary; let numberColor = theme.text.primary; if (isSelected) { textColor = theme.status.success; numberColor = theme.status.success; } else if (item.disabled) { textColor = theme.text.secondary; numberColor = theme.text.secondary; } if (!showNumbers) { numberColor = theme.text.secondary; } const numberColumnWidth = String(items.length).length; const itemNumberText = `${String(itemIndex + 1).padStart( numberColumnWidth, )}.`; return ( {isSelected ? '●' : ' '} {itemNumberText} {item.themeNameDisplay && item.themeTypeDisplay ? ( {item.themeNameDisplay}{' '} {item.themeTypeDisplay} ) : ( {item.label} )} ); })} {showScrollArrows && ( )} ); }