diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index dfb28e6931..4d2258b213 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -43,7 +43,7 @@ export const TOGGLE_TYPES: ReadonlySet = new Set([ 'enum', ]); -interface SettingEnumOption { +export interface SettingEnumOption { value: string | number; label: string; } diff --git a/packages/cli/src/ui/components/shared/EnumSelector.test.tsx b/packages/cli/src/ui/components/shared/EnumSelector.test.tsx new file mode 100644 index 0000000000..be2df513f9 --- /dev/null +++ b/packages/cli/src/ui/components/shared/EnumSelector.test.tsx @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { EnumSelector } from './EnumSelector.js'; +import type { SettingEnumOption } from '../../../config/settingsSchema.js'; +import { describe, it, expect } from 'vitest'; + +const LANGUAGE_OPTIONS: readonly SettingEnumOption[] = [ + { label: 'English', value: 'en' }, + { label: '中文 (简体)', value: 'zh' }, + { label: 'Español', value: 'es' }, + { label: 'Français', value: 'fr' }, +]; + +const NUMERIC_OPTIONS: readonly SettingEnumOption[] = [ + { label: 'Low', value: 1 }, + { label: 'Medium', value: 2 }, + { label: 'High', value: 3 }, +]; + +describe('', () => { + it('renders with string options and matches snapshot', () => { + const { lastFrame } = renderWithProviders( + {}} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders with numeric options and matches snapshot', () => { + const { lastFrame } = renderWithProviders( + {}} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders inactive state and matches snapshot', () => { + const { lastFrame } = renderWithProviders( + {}} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders with single option and matches snapshot', () => { + const singleOption: readonly SettingEnumOption[] = [ + { label: 'Only Option', value: 'only' }, + ]; + const { lastFrame } = renderWithProviders( + {}} + />, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders nothing when no options are provided', () => { + const { lastFrame } = renderWithProviders( + {}} + />, + ); + expect(lastFrame()).toBe(''); + }); + + it('handles currentValue not found in options', () => { + const { lastFrame } = renderWithProviders( + {}} + />, + ); + // Should default to first option + expect(lastFrame()).toContain('English'); + }); + + it('updates when currentValue changes externally', () => { + const { rerender, lastFrame } = renderWithProviders( + {}} + />, + ); + expect(lastFrame()).toContain('English'); + + rerender( + {}} + />, + ); + expect(lastFrame()).toContain('中文 (简体)'); + }); + + it('shows navigation arrows when multiple options available', () => { + const { lastFrame } = renderWithProviders( + {}} + />, + ); + expect(lastFrame()).toContain('←'); + expect(lastFrame()).toContain('→'); + }); + + it('hides navigation arrows when single option available', () => { + const singleOption: readonly SettingEnumOption[] = [ + { label: 'Only Option', value: 'only' }, + ]; + const { lastFrame } = renderWithProviders( + {}} + />, + ); + expect(lastFrame()).not.toContain('←'); + expect(lastFrame()).not.toContain('→'); + }); +}); diff --git a/packages/cli/src/ui/components/shared/EnumSelector.tsx b/packages/cli/src/ui/components/shared/EnumSelector.tsx new file mode 100644 index 0000000000..a86efd8ff1 --- /dev/null +++ b/packages/cli/src/ui/components/shared/EnumSelector.tsx @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import type React from 'react'; +import { Box, Text } from 'ink'; +import { Colors } from '../../colors.js'; +import type { SettingEnumOption } from '../../../config/settingsSchema.js'; + +interface EnumSelectorProps { + options: readonly SettingEnumOption[]; + currentValue: string | number; + isActive: boolean; + onValueChange: (value: string | number) => void; +} + +/** + * A left-right scrolling selector for enum values + */ +export function EnumSelector({ + options, + currentValue, + isActive, + onValueChange: _onValueChange, +}: EnumSelectorProps): React.JSX.Element { + const [currentIndex, setCurrentIndex] = useState(() => { + // Guard against empty options array + if (!options || options.length === 0) { + return 0; + } + const index = options.findIndex((option) => option.value === currentValue); + return index >= 0 ? index : 0; + }); + + // Update index when currentValue changes externally + useEffect(() => { + // Guard against empty options array + if (!options || options.length === 0) { + return; + } + const index = options.findIndex((option) => option.value === currentValue); + // Always update index, defaulting to 0 if value not found + setCurrentIndex(index >= 0 ? index : 0); + }, [currentValue, options]); + + // Guard against empty options array + if (!options || options.length === 0) { + return ; + } + + // Left/right navigation is handled by parent component + // This component is purely for display + // onValueChange is kept for interface compatibility but not used internally + + const currentOption = options[currentIndex] || options[0]; + const canScrollLeft = options.length > 1; + const canScrollRight = options.length > 1; + + return ( + + + {canScrollLeft ? '←' : ' '} + + + + {currentOption.label} + + + + {canScrollRight ? '→' : ' '} + + + ); +} + +// Export the interface for external use +export type { EnumSelectorProps }; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap new file mode 100644 index 0000000000..9949aba5e4 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders inactive state and matches snapshot 1`] = `"← 中文 (简体) →"`; + +exports[` > renders with numeric options and matches snapshot 1`] = `"← Medium →"`; + +exports[` > renders with single option and matches snapshot 1`] = `" Only Option"`; + +exports[` > renders with string options and matches snapshot 1`] = `"← English →"`;