summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
diff options
context:
space:
mode:
authorMiguel Solorio <[email protected]>2025-07-17 15:51:42 -0700
committerGitHub <[email protected]>2025-07-17 22:51:42 +0000
commit5b7bf74d66645163db14e6f1f7aea1f31d8b5f8a (patch)
tree81997804659dc551d9c9cb3fbb9c719166612f33 /packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
parent6aac93ee075757fcb0b840012ff0abf0b17feea1 (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.tsx95
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}{' '}