Add numbers to selection list (#4320)
This commit is contained in:
parent
6aac93ee07
commit
5b7bf74d66
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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('');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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) {
|
|
||||||
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;
|
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}{' '}
|
||||||
|
|
|
@ -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"
|
||||||
|
`;
|
Loading…
Reference in New Issue