refactor(cli): Extract reusable BaseSelectionList component and modernize RadioButtonSelect tests (#9021)

This commit is contained in:
Abhi
2025-09-22 11:14:41 -04:00
committed by GitHub
parent edd988be60
commit 81d03cb524
10 changed files with 2066 additions and 399 deletions

View File

@@ -0,0 +1,521 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../../test-utils/render.js';
import {
BaseSelectionList,
type BaseSelectionListProps,
type RenderItemContext,
} from './BaseSelectionList.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { Text } from 'ink';
import type { theme } from '../../semantic-colors.js';
vi.mock('../../hooks/useSelectionList.js');
const mockTheme = {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
status: { success: 'COLOR_SUCCESS' },
} as typeof theme;
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
status: { success: 'COLOR_SUCCESS' },
},
}));
describe('BaseSelectionList', () => {
const mockOnSelect = vi.fn();
const mockOnHighlight = vi.fn();
const mockRenderItem = vi.fn();
// Define standard test items
const items = [
{ value: 'A', label: 'Item A' },
{ value: 'B', label: 'Item B', disabled: true },
{ value: 'C', label: 'Item C' },
];
// Helper to render the component with default props
const renderComponent = (
props: Partial<BaseSelectionListProps<string, { label: string }>> = {},
activeIndex: number = 0,
) => {
vi.mocked(useSelectionList).mockReturnValue({
activeIndex,
setActiveIndex: vi.fn(),
});
mockRenderItem.mockImplementation(
(item: (typeof items)[0], context: RenderItemContext) => (
<Text color={context.titleColor}>{item.label}</Text>
),
);
const defaultProps: BaseSelectionListProps<string, { label: string }> = {
items,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
renderItem: mockRenderItem,
...props,
};
return renderWithProviders(<BaseSelectionList {...defaultProps} />);
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering and Structure', () => {
it('should render all items using the renderItem prop', () => {
const { lastFrame } = renderComponent();
expect(lastFrame()).toContain('Item A');
expect(lastFrame()).toContain('Item B');
expect(lastFrame()).toContain('Item C');
expect(mockRenderItem).toHaveBeenCalledTimes(3);
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
});
it('should render the selection indicator (● or space) and layout', () => {
const { lastFrame } = renderComponent({}, 0);
const output = lastFrame();
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
expect(output).toMatch(/●\s+1\.\s+Item A/);
expect(output).toMatch(/\s+2\.\s+Item B/);
expect(output).toMatch(/\s+3\.\s+Item C/);
});
it('should handle an empty list gracefully', () => {
const { lastFrame } = renderComponent({ items: [] });
expect(mockRenderItem).not.toHaveBeenCalled();
expect(lastFrame()).toBe('');
});
});
describe('useSelectionList Integration', () => {
it('should pass props correctly to useSelectionList', () => {
const initialIndex = 1;
const isFocused = false;
const showNumbers = false;
renderComponent({ initialIndex, isFocused, showNumbers });
expect(useSelectionList).toHaveBeenCalledWith({
items,
initialIndex,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
isFocused,
showNumbers,
});
});
it('should use the activeIndex returned by the hook', () => {
renderComponent({}, 2); // Active index is C
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
expect.objectContaining({ isSelected: false }),
);
expect(mockRenderItem).toHaveBeenCalledWith(
items[2],
expect.objectContaining({ isSelected: true }),
);
});
});
describe('Styling and Colors', () => {
it('should apply success color to the selected item', () => {
renderComponent({}, 0); // Item A selected
// Check renderItem context colors against the mocked theme
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
expect.objectContaining({
titleColor: mockTheme.status.success,
numberColor: mockTheme.status.success,
isSelected: true,
}),
);
});
it('should apply primary color to unselected, enabled items', () => {
renderComponent({}, 0); // Item A selected, Item C unselected/enabled
// Check renderItem context colors for Item C
expect(mockRenderItem).toHaveBeenCalledWith(
items[2],
expect.objectContaining({
titleColor: mockTheme.text.primary,
numberColor: mockTheme.text.primary,
isSelected: false,
}),
);
});
it('should apply secondary color to disabled items (when not selected)', () => {
renderComponent({}, 0); // Item A selected, Item B disabled
// Check renderItem context colors for Item B
expect(mockRenderItem).toHaveBeenCalledWith(
items[1],
expect.objectContaining({
titleColor: mockTheme.text.secondary,
numberColor: mockTheme.text.secondary,
isSelected: false,
}),
);
});
it('should apply success color to disabled items if they are selected', () => {
// The component should visually reflect the selection even if the item is disabled.
renderComponent({}, 1); // Item B (disabled) selected
// Check renderItem context colors for Item B
expect(mockRenderItem).toHaveBeenCalledWith(
items[1],
expect.objectContaining({
titleColor: mockTheme.status.success,
numberColor: mockTheme.status.success,
isSelected: true,
}),
);
});
});
describe('Numbering (showNumbers)', () => {
it('should show numbers by default with correct formatting', () => {
const { lastFrame } = renderComponent();
const output = lastFrame();
expect(output).toContain('1.');
expect(output).toContain('2.');
expect(output).toContain('3.');
});
it('should hide numbers when showNumbers is false', () => {
const { lastFrame } = renderComponent({ showNumbers: false });
const output = lastFrame();
expect(output).not.toContain('1.');
expect(output).not.toContain('2.');
expect(output).not.toContain('3.');
});
it('should apply correct padding for alignment in long lists', () => {
const longList = Array.from({ length: 15 }, (_, i) => ({
value: `Item ${i + 1}`,
label: `Item ${i + 1}`,
}));
// We must increase maxItemsToShow (default 10) to see the 10th item and beyond
const { lastFrame } = renderComponent({
items: longList,
maxItemsToShow: 15,
});
const output = lastFrame();
// Check formatting for single and double digits.
// The implementation uses padStart, resulting in " 1." and "10.".
expect(output).toContain(' 1.');
expect(output).toContain('10.');
});
it('should apply secondary color to numbers if showNumbers is false (internal logic check)', () => {
renderComponent({ showNumbers: false }, 0);
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
expect.objectContaining({
isSelected: true,
titleColor: mockTheme.status.success,
numberColor: mockTheme.text.secondary,
}),
);
});
});
describe('Scrolling and Pagination (maxItemsToShow)', () => {
const longList = Array.from({ length: 10 }, (_, i) => ({
value: `Item ${i + 1}`,
label: `Item ${i + 1}`,
}));
const MAX_ITEMS = 3;
const renderScrollableList = (initialActiveIndex: number = 0) => {
// Define the props used for the initial render and subsequent rerenders
const componentProps: BaseSelectionListProps<string, { label: string }> =
{
items: longList,
maxItemsToShow: MAX_ITEMS,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
renderItem: mockRenderItem,
};
vi.mocked(useSelectionList).mockReturnValue({
activeIndex: initialActiveIndex,
setActiveIndex: vi.fn(),
});
mockRenderItem.mockImplementation(
(item: (typeof longList)[0], context: RenderItemContext) => (
<Text color={context.titleColor}>{item.label}</Text>
),
);
const { rerender, lastFrame } = renderWithProviders(
<BaseSelectionList {...componentProps} />,
);
// Function to simulate the activeIndex changing over time
const updateActiveIndex = async (newIndex: number) => {
vi.mocked(useSelectionList).mockReturnValue({
activeIndex: newIndex,
setActiveIndex: vi.fn(),
});
rerender(<BaseSelectionList {...componentProps} />);
await waitFor(() => {
expect(lastFrame()).toBeTruthy();
});
};
return { updateActiveIndex, lastFrame };
};
it('should only show maxItemsToShow items initially', () => {
const { lastFrame } = renderScrollableList(0);
const output = lastFrame();
expect(output).toContain('Item 1');
expect(output).toContain('Item 3');
expect(output).not.toContain('Item 4');
});
it('should scroll down when activeIndex moves beyond the visible window', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
// Move to index 3 (Item 4). Should trigger scroll.
// New visible window should be Items 2, 3, 4 (scroll offset 1).
await updateActiveIndex(3);
const output = lastFrame();
expect(output).not.toContain('Item 1');
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 5');
});
it('should scroll up when activeIndex moves before the visible window', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
await updateActiveIndex(4);
let output = lastFrame();
expect(output).toContain('Item 3'); // Should see items 3, 4, 5
expect(output).toContain('Item 5');
expect(output).not.toContain('Item 2');
// Now test scrolling up: move to index 1 (Item 2)
// This should trigger scroll up to show items 2, 3, 4
await updateActiveIndex(1);
output = lastFrame();
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 5'); // Item 5 should no longer be visible
});
it('should pin the scroll offset to the end if selection starts near the end', async () => {
// List length 10. Max items 3. Active index 9 (last item).
// Scroll offset should be 10 - 3 = 7.
// Visible items: 8, 9, 10.
const { lastFrame } = renderScrollableList(9);
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Item 10');
expect(output).toContain('Item 8');
expect(output).not.toContain('Item 7');
});
});
it('should handle dynamic scrolling through multiple activeIndex changes', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 3');
// Scroll down gradually
await updateActiveIndex(2); // Still within window
expect(lastFrame()).toContain('Item 1');
await updateActiveIndex(3); // Should trigger scroll
let output = lastFrame();
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 1');
await updateActiveIndex(5); // Scroll further
output = lastFrame();
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
});
it('should correctly identify the selected item within the visible window', () => {
renderScrollableList(1); // activeIndex 1 = Item 2
expect(mockRenderItem).toHaveBeenCalledTimes(MAX_ITEMS);
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 1' }),
expect.objectContaining({ isSelected: false }),
);
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 2' }),
expect.objectContaining({ isSelected: true }),
);
});
it('should correctly identify the selected item when scrolled (high index)', async () => {
renderScrollableList(5);
await waitFor(() => {
// Item 6 (index 5) should be selected
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 6' }),
expect.objectContaining({ isSelected: true }),
);
});
// Item 4 (index 3) should not be selected
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 4' }),
expect.objectContaining({ isSelected: false }),
);
});
it('should handle maxItemsToShow larger than the list length', () => {
// Test edge case where maxItemsToShow exceeds available items
const { lastFrame } = renderComponent(
{ items: longList, maxItemsToShow: 15 },
0,
);
const output = lastFrame();
// Should show all available items (10 items)
expect(output).toContain('Item 1');
expect(output).toContain('Item 10');
expect(mockRenderItem).toHaveBeenCalledTimes(10);
});
});
describe('Scroll Arrows (showScrollArrows)', () => {
const longList = Array.from({ length: 10 }, (_, i) => ({
value: `Item ${i + 1}`,
label: `Item ${i + 1}`,
}));
const MAX_ITEMS = 3;
it('should not show arrows by default', () => {
const { lastFrame } = renderComponent({
items: longList,
maxItemsToShow: MAX_ITEMS,
});
const output = lastFrame();
expect(output).not.toContain('▲');
expect(output).not.toContain('▼');
});
it('should show arrows with correct colors when enabled (at the top)', async () => {
const { lastFrame } = renderComponent(
{
items: longList,
maxItemsToShow: MAX_ITEMS,
showScrollArrows: true,
},
0,
);
await waitFor(() => {
const output = lastFrame();
// At the top, should show first 3 items
expect(output).toContain('Item 1');
expect(output).toContain('Item 3');
expect(output).not.toContain('Item 4');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
});
});
it('should show arrows and correct items when scrolled to the middle', async () => {
const { lastFrame } = renderComponent(
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
5,
);
await waitFor(() => {
const output = lastFrame();
// After scrolling to middle, should see items around index 5
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
expect(output).not.toContain('Item 7');
// Both scroll arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
});
});
it('should show arrows and correct items when scrolled to the end', async () => {
const { lastFrame } = renderComponent(
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
9,
);
await waitFor(() => {
const output = lastFrame();
// At the end, should show last 3 items
expect(output).toContain('Item 8');
expect(output).toContain('Item 10');
expect(output).not.toContain('Item 7');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
});
});
it('should show both arrows dimmed when list fits entirely', () => {
const { lastFrame } = renderComponent({
items,
maxItemsToShow: 5,
showScrollArrows: true,
});
const output = lastFrame();
// Should show all items since maxItemsToShow > items.length
expect(output).toContain('Item A');
expect(output).toContain('Item B');
expect(output).toContain('Item C');
// Both arrows should be visible but dimmed (this test doesn't need waitFor since no scrolling occurs)
expect(output).toContain('▲');
expect(output).toContain('▼');
});
});
});

View File

@@ -0,0 +1,174 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
export interface RenderItemContext {
isSelected: boolean;
titleColor: string;
numberColor: string;
}
export interface BaseSelectionListProps<T, TItem = Record<string, unknown>> {
items: Array<TItem & { value: T; disabled?: boolean }>;
initialIndex?: number;
onSelect: (value: T) => void;
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
renderItem: (
item: TItem & { value: T; disabled?: boolean },
context: RenderItemContext,
) => React.ReactNode;
}
/**
* Base component for selection lists that provides common UI structure
* and keyboard navigation logic via the useSelectionList hook.
*
* This component handles:
* - Radio button indicators
* - Item numbering
* - Scrolling for long lists
* - Color theming based on selection/disabled state
* - Keyboard navigation and numeric selection
*
* Specific components should use this as a base and provide
* their own renderItem implementation for custom content.
*/
export function BaseSelectionList<T, TItem = Record<string, unknown>>({
items,
initialIndex = 0,
onSelect,
onHighlight,
isFocused = true,
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
items,
initialIndex,
onSelect,
onHighlight,
isFocused,
showNumbers,
});
const [scrollOffset, setScrollOffset] = useState(0);
// Handle scrolling for long lists
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]);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
const numberColumnWidth = String(items.length).length;
return (
<Box flexDirection="column">
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && (
<Text
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
>
</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isSelected = activeIndex === itemIndex;
// Determine colors based on selection and disabled state
let titleColor = theme.text.primary;
let numberColor = theme.text.primary;
if (isSelected) {
titleColor = theme.status.success;
numberColor = theme.status.success;
} else if (item.disabled) {
titleColor = theme.text.secondary;
numberColor = theme.text.secondary;
}
if (!isFocused && !item.disabled) {
numberColor = theme.text.secondary;
}
if (!showNumbers) {
numberColor = theme.text.secondary;
}
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
return (
<Box key={itemIndex} alignItems="flex-start">
{/* Radio button indicator */}
<Box minWidth={2} flexShrink={0}>
<Text
color={isSelected ? theme.status.success : theme.text.primary}
aria-hidden
>
{isSelected ? '●' : ' '}
</Text>
</Box>
{/* Item number */}
{showNumbers && (
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
)}
{/* Custom content via render prop */}
<Box flexGrow={1}>
{renderItem(item, {
isSelected,
titleColor,
numberColor,
})}
</Box>
</Box>
);
})}
{showScrollArrows && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
? theme.text.primary
: theme.text.secondary
}
>
</Text>
)}
</Box>
);
}

View File

@@ -4,178 +4,193 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import type React from 'react';
import {
RadioButtonSelect,
type RadioSelectItem,
type RadioButtonSelectProps,
} from './RadioButtonSelect.js';
import { describe, it, expect, vi } from 'vitest';
import {
BaseSelectionList,
type BaseSelectionListProps,
type RenderItemContext,
} from './BaseSelectionList.js';
const ITEMS: Array<RadioSelectItem<string>> = [
{ label: 'Option 1', value: 'one' },
{ label: 'Option 2', value: 'two' },
{ label: 'Option 3', value: 'three', disabled: true },
];
vi.mock('./BaseSelectionList.js', () => ({
BaseSelectionList: vi.fn(() => null),
}));
describe('<RadioButtonSelect />', () => {
it('renders a list of items and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: { secondary: 'COLOR_SECONDARY' },
},
}));
const MockedBaseSelectionList = vi.mocked(
BaseSelectionList,
) as unknown as ReturnType<typeof vi.fn>;
type RadioRenderItemFn = (
item: RadioSelectItem<string>,
context: RenderItemContext,
) => React.JSX.Element;
const extractRenderItem = (): RadioRenderItemFn => {
const mockCalls = MockedBaseSelectionList.mock.calls;
if (mockCalls.length === 0) {
throw new Error(
'BaseSelectionList was not called. Ensure RadioButtonSelect is rendered before calling extractRenderItem.',
);
expect(lastFrame()).toMatchSnapshot();
}
const props = mockCalls[0][0] as BaseSelectionListProps<
string,
RadioSelectItem<string>
>;
if (typeof props.renderItem !== 'function') {
throw new Error('renderItem prop was not found on BaseSelectionList call.');
}
return props.renderItem as RadioRenderItemFn;
};
describe('RadioButtonSelect', () => {
const mockOnSelect = vi.fn();
const mockOnHighlight = vi.fn();
const ITEMS: Array<RadioSelectItem<string>> = [
{ label: 'Option 1', value: 'one' },
{ label: 'Option 2', value: 'two' },
{ label: 'Option 3', value: 'three', disabled: true },
];
const renderComponent = (
props: Partial<RadioButtonSelectProps<string>> = {},
) => {
const defaultProps: RadioButtonSelectProps<string> = {
items: ITEMS,
onSelect: mockOnSelect,
...props,
};
return renderWithProviders(<RadioButtonSelect {...defaultProps} />);
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with the second item selected and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={ITEMS} initialIndex={1} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
describe('Prop forwarding to BaseSelectionList', () => {
it('should forward all props correctly when provided', () => {
const props = {
items: ITEMS,
initialIndex: 1,
onSelect: mockOnSelect,
onHighlight: mockOnHighlight,
isFocused: false,
showScrollArrows: true,
maxItemsToShow: 5,
showNumbers: false,
};
renderComponent(props);
expect(BaseSelectionList).toHaveBeenCalledTimes(1);
expect(BaseSelectionList).toHaveBeenCalledWith(
expect.objectContaining({
...props,
renderItem: expect.any(Function),
}),
undefined,
);
});
it('should use default props if not provided', () => {
renderComponent({
items: ITEMS,
onSelect: mockOnSelect,
});
expect(BaseSelectionList).toHaveBeenCalledWith(
expect.objectContaining({
initialIndex: 0,
isFocused: true,
showScrollArrows: false,
maxItemsToShow: 10,
showNumbers: true,
}),
undefined,
);
});
});
it('renders with numbers hidden and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={() => {}}
showNumbers={false}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
describe('renderItem implementation', () => {
let renderItem: RadioRenderItemFn;
const mockContext: RenderItemContext = {
isSelected: false,
titleColor: 'MOCK_TITLE_COLOR',
numberColor: 'MOCK_NUMBER_COLOR',
};
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 } = renderWithProviders(
<RadioButtonSelect
items={manyItems}
onSelect={() => {}}
showScrollArrows={true}
maxItemsToShow={5}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
beforeEach(() => {
renderComponent();
renderItem = extractRenderItem();
});
it('renders with special theme display and matches snapshot', () => {
const themeItems: Array<RadioSelectItem<string>> = [
{
it('should render the standard label display with correct color and truncation', () => {
const item = ITEMS[0];
const result = renderItem(item, mockContext);
expect(result?.props?.color).toBe(mockContext.titleColor);
expect(result?.props?.children).toBe('Option 1');
expect(result?.props?.wrap).toBe('truncate');
});
it('should render the special theme display when theme props are present', () => {
const themeItem: 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 } = renderWithProviders(
<RadioButtonSelect items={themeItems} onSelect={() => {}} />,
);
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 } = renderWithProviders(
<RadioButtonSelect items={manyItems} onSelect={() => {}} />,
);
expect(lastFrame()).toMatchSnapshot();
});
const result = renderItem(themeItem, mockContext);
it('renders nothing when no items are provided', () => {
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
);
expect(lastFrame()).toBe('');
});
});
expect(result?.props?.color).toBe(mockContext.titleColor);
expect(result?.props?.wrap).toBe('truncate');
describe('keyboard navigation', () => {
it('should call onSelect when "enter" is pressed', () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<RadioButtonSelect items={ITEMS} onSelect={onSelect} />,
);
const children = result?.props?.children;
stdin.write('\r');
if (!Array.isArray(children) || children.length < 3) {
throw new Error(
'Expected children to be an array with at least 3 elements for theme display',
);
}
expect(onSelect).toHaveBeenCalledWith('one');
});
expect(children[0]).toBe('Theme A');
expect(children[1]).toBe(' ');
describe('when isFocused is false', () => {
it('should not handle any keyboard input', () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
isFocused={false}
/>,
);
stdin.write('\u001B[B'); // Down arrow
stdin.write('\u001B[A'); // Up arrow
stdin.write('\r'); // Enter
expect(onSelect).not.toHaveBeenCalled();
});
});
describe.each([
{ description: 'when isFocused is true', isFocused: true },
{ description: 'when isFocused is omitted', isFocused: undefined },
])('$description', ({ isFocused }) => {
it('should navigate down with arrow key and select with enter', async () => {
const onSelect = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
isFocused={isFocused}
/>,
);
stdin.write('\u001B[B'); // Down arrow
await waitFor(() => {
expect(lastFrame()).toContain('● 2. Option 2');
});
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('two');
const nestedTextElement = children[2] as React.ReactElement<{
color?: string;
children?: React.ReactNode;
}>;
expect(nestedTextElement?.props?.color).toBe('COLOR_SECONDARY');
expect(nestedTextElement?.props?.children).toBe('(Light)');
});
it('should navigate up with arrow key and select with enter', async () => {
const onSelect = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<RadioButtonSelect
items={ITEMS}
onSelect={onSelect}
initialIndex={1}
isFocused={isFocused}
/>,
);
it('should fall back to standard display if only one theme prop is present', () => {
const partialThemeItem: RadioSelectItem<string> = {
label: 'Incomplete Theme',
value: 'incomplete',
themeNameDisplay: 'Only Name',
};
stdin.write('\u001B[A'); // Up arrow
const result = renderItem(partialThemeItem, mockContext);
await waitFor(() => {
expect(lastFrame()).toContain('● 1. Option 1');
});
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('one');
expect(result?.props?.children).toBe('Incomplete Theme');
});
});
});

View File

@@ -5,10 +5,9 @@
*/
import type React from 'react';
import { useEffect, useState, useRef } from 'react';
import { Text, Box } from 'ink';
import { Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { BaseSelectionList } from './BaseSelectionList.js';
/**
* Represents a single option for the RadioButtonSelect.
@@ -61,182 +60,33 @@ export function RadioButtonSelect<T>({
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(
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 (
<Box flexDirection="column">
{showScrollArrows && (
<Text
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
>
</Text>
)}
{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;
<BaseSelectionList<T, RadioSelectItem<T>>
items={items}
initialIndex={initialIndex}
onSelect={onSelect}
onHighlight={onHighlight}
isFocused={isFocused}
showNumbers={showNumbers}
showScrollArrows={showScrollArrows}
maxItemsToShow={maxItemsToShow}
renderItem={(item, { titleColor }) => {
// Handle special theme display case for ThemeDialog compatibility
if (item.themeNameDisplay && item.themeTypeDisplay) {
return (
<Text color={titleColor} wrap="truncate">
{item.themeNameDisplay}{' '}
<Text color={theme.text.secondary}>{item.themeTypeDisplay}</Text>
</Text>
);
}
if (!showNumbers) {
numberColor = theme.text.secondary;
}
const numberColumnWidth = String(items.length).length;
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
// Regular label display
return (
<Box key={item.label} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text
color={isSelected ? theme.status.success : theme.text.primary}
aria-hidden
>
{isSelected ? '●' : ' '}
</Text>
</Box>
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
{item.themeNameDisplay && item.themeTypeDisplay ? (
<Text color={textColor} wrap="truncate">
{item.themeNameDisplay}{' '}
<Text color={theme.text.secondary}>
{item.themeTypeDisplay}
</Text>
</Text>
) : (
<Text color={textColor} wrap="truncate">
{item.label}
</Text>
)}
</Box>
<Text color={titleColor} wrap="truncate">
{item.label}
</Text>
);
})}
{showScrollArrows && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
? theme.text.primary
: theme.text.secondary
}
>
</Text>
)}
</Box>
}}
/>
);
}

View File

@@ -1,47 +0,0 @@
// 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"
`;