Skip to content

Commit 7b089c4

Browse files
committed
Merge Commit 'da58b93': feat: Add reusable EnumSelector UI component (split from google-gemini#6832) (google-gemini#7774)
2 parents 90bc864 + da58b93 commit 7b089c4

4 files changed

Lines changed: 249 additions & 1 deletion

File tree

packages/cli/src/config/settingsSchema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const TOGGLE_TYPES: ReadonlySet<SettingsType | undefined> = new Set([
4444
'enum',
4545
]);
4646

47-
interface SettingEnumOption {
47+
export interface SettingEnumOption {
4848
value: string | number;
4949
label: string;
5050
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { renderWithProviders } from '../../../test-utils/render.js';
8+
import { EnumSelector } from './EnumSelector.js';
9+
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
10+
import { describe, it, expect } from 'vitest';
11+
12+
const LANGUAGE_OPTIONS: readonly SettingEnumOption[] = [
13+
{ label: 'English', value: 'en' },
14+
{ label: '中文 (简体)', value: 'zh' },
15+
{ label: 'Español', value: 'es' },
16+
{ label: 'Français', value: 'fr' },
17+
];
18+
19+
const NUMERIC_OPTIONS: readonly SettingEnumOption[] = [
20+
{ label: 'Low', value: 1 },
21+
{ label: 'Medium', value: 2 },
22+
{ label: 'High', value: 3 },
23+
];
24+
25+
describe('<EnumSelector />', () => {
26+
it('renders with string options and matches snapshot', () => {
27+
const { lastFrame } = renderWithProviders(
28+
<EnumSelector
29+
options={LANGUAGE_OPTIONS}
30+
currentValue="en"
31+
isActive={true}
32+
onValueChange={() => {}}
33+
/>,
34+
);
35+
expect(lastFrame()).toMatchSnapshot();
36+
});
37+
38+
it('renders with numeric options and matches snapshot', () => {
39+
const { lastFrame } = renderWithProviders(
40+
<EnumSelector
41+
options={NUMERIC_OPTIONS}
42+
currentValue={2}
43+
isActive={true}
44+
onValueChange={() => {}}
45+
/>,
46+
);
47+
expect(lastFrame()).toMatchSnapshot();
48+
});
49+
50+
it('renders inactive state and matches snapshot', () => {
51+
const { lastFrame } = renderWithProviders(
52+
<EnumSelector
53+
options={LANGUAGE_OPTIONS}
54+
currentValue="zh"
55+
isActive={false}
56+
onValueChange={() => {}}
57+
/>,
58+
);
59+
expect(lastFrame()).toMatchSnapshot();
60+
});
61+
62+
it('renders with single option and matches snapshot', () => {
63+
const singleOption: readonly SettingEnumOption[] = [
64+
{ label: 'Only Option', value: 'only' },
65+
];
66+
const { lastFrame } = renderWithProviders(
67+
<EnumSelector
68+
options={singleOption}
69+
currentValue="only"
70+
isActive={true}
71+
onValueChange={() => {}}
72+
/>,
73+
);
74+
expect(lastFrame()).toMatchSnapshot();
75+
});
76+
77+
it('renders nothing when no options are provided', () => {
78+
const { lastFrame } = renderWithProviders(
79+
<EnumSelector
80+
options={[]}
81+
currentValue=""
82+
isActive={true}
83+
onValueChange={() => {}}
84+
/>,
85+
);
86+
expect(lastFrame()).toBe('');
87+
});
88+
89+
it('handles currentValue not found in options', () => {
90+
const { lastFrame } = renderWithProviders(
91+
<EnumSelector
92+
options={LANGUAGE_OPTIONS}
93+
currentValue="invalid"
94+
isActive={true}
95+
onValueChange={() => {}}
96+
/>,
97+
);
98+
// Should default to first option
99+
expect(lastFrame()).toContain('English');
100+
});
101+
102+
it('updates when currentValue changes externally', () => {
103+
const { rerender, lastFrame } = renderWithProviders(
104+
<EnumSelector
105+
options={LANGUAGE_OPTIONS}
106+
currentValue="en"
107+
isActive={true}
108+
onValueChange={() => {}}
109+
/>,
110+
);
111+
expect(lastFrame()).toContain('English');
112+
113+
rerender(
114+
<EnumSelector
115+
options={LANGUAGE_OPTIONS}
116+
currentValue="zh"
117+
isActive={true}
118+
onValueChange={() => {}}
119+
/>,
120+
);
121+
expect(lastFrame()).toContain('中文 (简体)');
122+
});
123+
124+
it('shows navigation arrows when multiple options available', () => {
125+
const { lastFrame } = renderWithProviders(
126+
<EnumSelector
127+
options={LANGUAGE_OPTIONS}
128+
currentValue="en"
129+
isActive={true}
130+
onValueChange={() => {}}
131+
/>,
132+
);
133+
expect(lastFrame()).toContain('←');
134+
expect(lastFrame()).toContain('→');
135+
});
136+
137+
it('hides navigation arrows when single option available', () => {
138+
const singleOption: readonly SettingEnumOption[] = [
139+
{ label: 'Only Option', value: 'only' },
140+
];
141+
const { lastFrame } = renderWithProviders(
142+
<EnumSelector
143+
options={singleOption}
144+
currentValue="only"
145+
isActive={true}
146+
onValueChange={() => {}}
147+
/>,
148+
);
149+
expect(lastFrame()).not.toContain('←');
150+
expect(lastFrame()).not.toContain('→');
151+
});
152+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useState, useEffect } from 'react';
8+
import type React from 'react';
9+
import { Box, Text } from 'ink';
10+
import { Colors } from '../../colors.js';
11+
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
12+
13+
interface EnumSelectorProps {
14+
options: readonly SettingEnumOption[];
15+
currentValue: string | number;
16+
isActive: boolean;
17+
onValueChange: (value: string | number) => void;
18+
}
19+
20+
/**
21+
* A left-right scrolling selector for enum values
22+
*/
23+
export function EnumSelector({
24+
options,
25+
currentValue,
26+
isActive,
27+
onValueChange: _onValueChange,
28+
}: EnumSelectorProps): React.JSX.Element {
29+
const [currentIndex, setCurrentIndex] = useState(() => {
30+
// Guard against empty options array
31+
if (!options || options.length === 0) {
32+
return 0;
33+
}
34+
const index = options.findIndex((option) => option.value === currentValue);
35+
return index >= 0 ? index : 0;
36+
});
37+
38+
// Update index when currentValue changes externally
39+
useEffect(() => {
40+
// Guard against empty options array
41+
if (!options || options.length === 0) {
42+
return;
43+
}
44+
const index = options.findIndex((option) => option.value === currentValue);
45+
// Always update index, defaulting to 0 if value not found
46+
setCurrentIndex(index >= 0 ? index : 0);
47+
}, [currentValue, options]);
48+
49+
// Guard against empty options array
50+
if (!options || options.length === 0) {
51+
return <Box />;
52+
}
53+
54+
// Left/right navigation is handled by parent component
55+
// This component is purely for display
56+
// onValueChange is kept for interface compatibility but not used internally
57+
58+
const currentOption = options[currentIndex] || options[0];
59+
const canScrollLeft = options.length > 1;
60+
const canScrollRight = options.length > 1;
61+
62+
return (
63+
<Box flexDirection="row" alignItems="center">
64+
<Text
65+
color={isActive && canScrollLeft ? Colors.AccentGreen : Colors.Gray}
66+
>
67+
{canScrollLeft ? '←' : ' '}
68+
</Text>
69+
<Text> </Text>
70+
<Text
71+
color={isActive ? Colors.AccentGreen : Colors.Foreground}
72+
bold={isActive}
73+
>
74+
{currentOption.label}
75+
</Text>
76+
<Text> </Text>
77+
<Text
78+
color={isActive && canScrollRight ? Colors.AccentGreen : Colors.Gray}
79+
>
80+
{canScrollRight ? '→' : ' '}
81+
</Text>
82+
</Box>
83+
);
84+
}
85+
86+
// Export the interface for external use
87+
export type { EnumSelectorProps };
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`<EnumSelector /> > renders inactive state and matches snapshot 1`] = `"← 中文 (简体) →"`;
4+
5+
exports[`<EnumSelector /> > renders with numeric options and matches snapshot 1`] = `"← Medium →"`;
6+
7+
exports[`<EnumSelector /> > renders with single option and matches snapshot 1`] = `" Only Option"`;
8+
9+
exports[`<EnumSelector /> > renders with string options and matches snapshot 1`] = `"← English →"`;

0 commit comments

Comments
 (0)