Files
gemini-cli/packages/cli/src/ui/components/shared/BaseSelectionList.tsx

180 lines
4.8 KiB
TypeScript
Raw Normal View History

/**
* @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';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface RenderItemContext {
isSelected: boolean;
titleColor: string;
numberColor: string;
}
export interface BaseSelectionListProps<
T,
TItem extends SelectionListItem<T> = SelectionListItem<T>,
> {
items: TItem[];
initialIndex?: number;
onSelect: (value: T) => void;
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
renderItem: (item: TItem, 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 extends SelectionListItem<T> = SelectionListItem<T>,
>({
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={item.key} 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>
);
}