Add numbers to selection list (#4320)

This commit is contained in:
Miguel Solorio 2025-07-17 15:51:42 -07:00 committed by GitHub
parent 6aac93ee07
commit 5b7bf74d66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 255 additions and 16 deletions

View File

@ -165,7 +165,7 @@ describe('AuthDialog', () => {
); );
// This is a bit brittle, but it's the best way to check which item is selected. // This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● Login with Google'); expect(lastFrame()).toContain('● 1. Login with Google');
}); });
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => { it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
@ -188,7 +188,7 @@ describe('AuthDialog', () => {
); );
// Default is LOGIN_WITH_GOOGLE // Default is LOGIN_WITH_GOOGLE
expect(lastFrame()).toContain('● Login with Google'); expect(lastFrame()).toContain('● 1. Login with Google');
}); });
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => { it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
@ -217,7 +217,7 @@ describe('AuthDialog', () => {
); );
// Default is LOGIN_WITH_GOOGLE // Default is LOGIN_WITH_GOOGLE
expect(lastFrame()).toContain('● Login with Google'); expect(lastFrame()).toContain('● 1. Login with Google');
}); });
}); });

View File

@ -204,6 +204,7 @@ export function ThemeDialog({
isFocused={currenFocusedSection === 'theme'} isFocused={currenFocusedSection === 'theme'}
maxItemsToShow={8} maxItemsToShow={8}
showScrollArrows={true} showScrollArrows={true}
showNumbers={currenFocusedSection === 'theme'}
/> />
{/* Scope Selection */} {/* Scope Selection */}
@ -218,6 +219,7 @@ export function ThemeDialog({
onSelect={handleScopeSelect} onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight} onHighlight={handleScopeHighlight}
isFocused={currenFocusedSection === 'scope'} isFocused={currenFocusedSection === 'scope'}
showNumbers={currenFocusedSection === 'scope'}
/> />
</Box> </Box>
)} )}

View File

@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './RadioButtonSelect.js';
import { describe, it, expect } from 'vitest';
const ITEMS: Array<RadioSelectItem<string>> = [
{ label: 'Option 1', value: 'one' },
{ label: 'Option 2', value: 'two' },
{ label: 'Option 3', value: 'three', disabled: true },
];
describe('<RadioButtonSelect />', () => {
it('renders a list of items and matches snapshot', () => {
const { lastFrame } = render(
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with the second item selected and matches snapshot', () => {
const { lastFrame } = render(
<RadioButtonSelect
items={ITEMS}
initialIndex={1}
onSelect={() => {}}
isFocused={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with numbers hidden and matches snapshot', () => {
const { lastFrame } = render(
<RadioButtonSelect
items={ITEMS}
onSelect={() => {}}
isFocused={true}
showNumbers={false}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with scroll arrows and matches snapshot', () => {
const manyItems = Array.from({ length: 20 }, (_, i) => ({
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
const { lastFrame } = render(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
isFocused={true}
showScrollArrows={true}
maxItemsToShow={5}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with special theme display and matches snapshot', () => {
const themeItems: Array<RadioSelectItem<string>> = [
{
label: 'Theme A (Light)',
value: 'a-light',
themeNameDisplay: 'Theme A',
themeTypeDisplay: '(Light)',
},
{
label: 'Theme B (Dark)',
value: 'b-dark',
themeNameDisplay: 'Theme B',
themeTypeDisplay: '(Dark)',
},
];
const { lastFrame } = render(
<RadioButtonSelect
items={themeItems}
onSelect={() => {}}
isFocused={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a list with >10 items and matches snapshot', () => {
const manyItems = Array.from({ length: 12 }, (_, i) => ({
label: `Item ${i + 1}`,
value: `item-${i + 1}`,
}));
const { lastFrame } = render(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
isFocused={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders nothing when no items are provided', () => {
const { lastFrame } = render(
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toBe('');
});
});

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { Text, Box, useInput } from 'ink';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
@ -39,6 +39,8 @@ export interface RadioButtonSelectProps<T> {
showScrollArrows?: boolean; showScrollArrows?: boolean;
/** The maximum number of items to show at once. */ /** The maximum number of items to show at once. */
maxItemsToShow?: number; maxItemsToShow?: number;
/** Whether to show numbers next to items. */
showNumbers?: boolean;
} }
/** /**
@ -55,9 +57,12 @@ export function RadioButtonSelect<T>({
isFocused, isFocused,
showScrollArrows = false, showScrollArrows = false,
maxItemsToShow = 10, maxItemsToShow = 10,
showNumbers = true,
}: RadioButtonSelectProps<T>): React.JSX.Element { }: RadioButtonSelectProps<T>): React.JSX.Element {
const [activeIndex, setActiveIndex] = useState(initialIndex); const [activeIndex, setActiveIndex] = useState(initialIndex);
const [scrollOffset, setScrollOffset] = useState(0); const [scrollOffset, setScrollOffset] = useState(0);
const [numberInput, setNumberInput] = useState('');
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
const newScrollOffset = Math.max( const newScrollOffset = Math.max(
@ -71,30 +76,81 @@ export function RadioButtonSelect<T>({
} }
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]); }, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
useEffect(
() => () => {
if (numberInputTimer.current) {
clearTimeout(numberInputTimer.current);
}
},
[],
);
useInput( useInput(
(input, key) => { (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) { if (input === 'k' || key.upArrow) {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1; const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex); setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value); onHighlight?.(items[newIndex]!.value);
return;
} }
if (input === 'j' || key.downArrow) { if (input === 'j' || key.downArrow) {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0; const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex); setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value); onHighlight?.(items[newIndex]!.value);
} return;
if (key.return) {
onSelect(items[activeIndex]!.value);
} }
// Enable selection directly from number keys. if (key.return) {
if (/^[1-9]$/.test(input)) { onSelect(items[activeIndex]!.value);
const targetIndex = Number.parseInt(input, 10) - 1; return;
if (targetIndex >= 0 && targetIndex < visibleItems.length) { }
const selectedItem = visibleItems[targetIndex];
if (selectedItem) { // Handle numeric input for selection.
onSelect?.(selectedItem.value); 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; const isSelected = activeIndex === itemIndex;
let textColor = Colors.Foreground; let textColor = Colors.Foreground;
let numberColor = Colors.Foreground;
if (isSelected) { if (isSelected) {
textColor = Colors.AccentGreen; textColor = Colors.AccentGreen;
numberColor = Colors.AccentGreen;
} else if (item.disabled) { } else if (item.disabled) {
textColor = Colors.Gray; 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 ( return (
<Box key={item.label}> <Box key={item.label} alignItems="center">
<Box minWidth={2} flexShrink={0}> <Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}> <Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
{isSelected ? '●' : '○'} {isSelected ? '●' : ' '}
</Text> </Text>
</Box> </Box>
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
{item.themeNameDisplay && item.themeTypeDisplay ? ( {item.themeNameDisplay && item.themeTypeDisplay ? (
<Text color={textColor} wrap="truncate"> <Text color={textColor} wrap="truncate">
{item.themeNameDisplay}{' '} {item.themeNameDisplay}{' '}

View File

@ -0,0 +1,47 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<RadioButtonSelect /> > renders a list of items and matches snapshot 1`] = `
"● 1. Option 1
2. Option 2
3. Option 3"
`;
exports[`<RadioButtonSelect /> > renders a list with >10 items and matches snapshot 1`] = `
"● 1. Item 1
2. Item 2
3. Item 3
4. Item 4
5. Item 5
6. Item 6
7. Item 7
8. Item 8
9. Item 9
10. Item 10"
`;
exports[`<RadioButtonSelect /> > renders with numbers hidden and matches snapshot 1`] = `
"● 1. Option 1
2. Option 2
3. Option 3"
`;
exports[`<RadioButtonSelect /> > renders with scroll arrows and matches snapshot 1`] = `
"▲
● 1. Item 1
2. Item 2
3. Item 3
4. Item 4
5. Item 5
▼"
`;
exports[`<RadioButtonSelect /> > renders with special theme display and matches snapshot 1`] = `
"● 1. Theme A (Light)
2. Theme B (Dark)"
`;
exports[`<RadioButtonSelect /> > renders with the second item selected and matches snapshot 1`] = `
" 1. Option 1
● 2. Option 2
3. Option 3"
`;