diff options
| author | Miguel Solorio <[email protected]> | 2025-07-17 15:51:42 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-17 22:51:42 +0000 |
| commit | 5b7bf74d66645163db14e6f1f7aea1f31d8b5f8a (patch) | |
| tree | 81997804659dc551d9c9cb3fbb9c719166612f33 /packages/cli/src/ui/components/shared/RadioButtonSelect.tsx | |
| parent | 6aac93ee075757fcb0b840012ff0abf0b17feea1 (diff) | |
Add numbers to selection list (#4320)
Diffstat (limited to 'packages/cli/src/ui/components/shared/RadioButtonSelect.tsx')
| -rw-r--r-- | packages/cli/src/ui/components/shared/RadioButtonSelect.tsx | 95 |
1 files changed, 85 insertions, 10 deletions
diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx index 499c136a..8b0057ca 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { Text, Box, useInput } from 'ink'; import { Colors } from '../../colors.js'; @@ -39,6 +39,8 @@ export interface RadioButtonSelectProps<T> { showScrollArrows?: boolean; /** The maximum number of items to show at once. */ maxItemsToShow?: number; + /** Whether to show numbers next to items. */ + showNumbers?: boolean; } /** @@ -55,9 +57,12 @@ export function RadioButtonSelect<T>({ isFocused, showScrollArrows = false, maxItemsToShow = 10, + showNumbers = true, }: RadioButtonSelectProps<T>): React.JSX.Element { const [activeIndex, setActiveIndex] = useState(initialIndex); const [scrollOffset, setScrollOffset] = useState(0); + const [numberInput, setNumberInput] = useState(''); + const numberInputTimer = useRef<NodeJS.Timeout | null>(null); useEffect(() => { const newScrollOffset = Math.max( @@ -71,30 +76,81 @@ export function RadioButtonSelect<T>({ } }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); + useEffect( + () => () => { + if (numberInputTimer.current) { + clearTimeout(numberInputTimer.current); + } + }, + [], + ); + useInput( (input, key) => { + const isNumeric = showNumbers && /^[0-9]$/.test(input); + + // Any key press that is not a digit should clear the number input buffer. + if (!isNumeric && numberInputTimer.current) { + clearTimeout(numberInputTimer.current); + setNumberInput(''); + } + if (input === 'k' || key.upArrow) { const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; setActiveIndex(newIndex); onHighlight?.(items[newIndex]!.value); + return; } + if (input === 'j' || key.downArrow) { const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; setActiveIndex(newIndex); onHighlight?.(items[newIndex]!.value); + return; } + if (key.return) { onSelect(items[activeIndex]!.value); + return; } - // Enable selection directly from number keys. - if (/^[1-9]$/.test(input)) { - const targetIndex = Number.parseInt(input, 10) - 1; - if (targetIndex >= 0 && targetIndex < visibleItems.length) { - const selectedItem = visibleItems[targetIndex]; - if (selectedItem) { - onSelect?.(selectedItem.value); + // Handle numeric input for selection. + if (isNumeric) { + if (numberInputTimer.current) { + clearTimeout(numberInputTimer.current); + } + + const newNumberInput = numberInput + input; + 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(''); } } }, @@ -115,19 +171,38 @@ export function RadioButtonSelect<T>({ const isSelected = activeIndex === itemIndex; let textColor = Colors.Foreground; + let numberColor = Colors.Foreground; if (isSelected) { textColor = Colors.AccentGreen; + numberColor = Colors.AccentGreen; } else if (item.disabled) { textColor = Colors.Gray; + numberColor = Colors.Gray; + } + + if (!showNumbers) { + numberColor = Colors.Gray; } + const numberColumnWidth = String(items.length).length; + const itemNumberText = `${String(itemIndex + 1).padStart( + numberColumnWidth, + )}.`; + return ( - <Box key={item.label}> + <Box key={item.label} alignItems="center"> <Box minWidth={2} flexShrink={0}> <Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}> - {isSelected ? '●' : '○'} + {isSelected ? '●' : ' '} </Text> </Box> + <Box + marginRight={1} + flexShrink={0} + minWidth={itemNumberText.length} + > + <Text color={numberColor}>{itemNumberText}</Text> + </Box> {item.themeNameDisplay && item.themeTypeDisplay ? ( <Text color={textColor} wrap="truncate"> {item.themeNameDisplay}{' '} |
