/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useEffect, useState, useLayoutEffect, useRef } from 'react'; import { Text, Box, getBoundingBox, type DOMElement } from 'ink'; import { theme } from '../../semantic-colors.js'; import { useSelectionList } from '../../hooks/useSelectionList.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { useUIState } from '../../contexts/UIStateContext.js'; import type { SelectionListItem } from '../../hooks/useSelectionList.js'; export interface RenderItemContext { isSelected: boolean; titleColor: string; numberColor: string; } export interface BaseSelectionListProps< T, TItem extends SelectionListItem = SelectionListItem, > { items: TItem[]; initialIndex?: number; onSelect: (value: T) => void; onHighlight?: (value: T) => void; isFocused?: boolean; showNumbers?: boolean; showScrollArrows?: boolean; maxItemsToShow?: number; wrapAround?: boolean; focusKey?: string; priority?: boolean; 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 = SelectionListItem, >({ items, initialIndex = 0, onSelect, onHighlight, isFocused = true, showNumbers = true, showScrollArrows = false, maxItemsToShow = 10, wrapAround = true, focusKey, priority, renderItem, }: BaseSelectionListProps): React.JSX.Element { const { activeIndex } = useSelectionList({ items, initialIndex, onSelect, onHighlight, isFocused, showNumbers, wrapAround, focusKey, priority, }); const [scrollOffset, setScrollOffset] = useState(0); const containerRef = useRef(null); const [horizontalOffset, setHorizontalOffset] = useState(0); const { columns: terminalWidth } = useTerminalSize(); const uiState = useUIState(); const mainAreaWidth = uiState?.mainAreaWidth; const effectiveTerminalWidth = mainAreaWidth ?? terminalWidth; // Measure horizontal offset to allow full-width highlight useLayoutEffect(() => { if (containerRef.current) { const { x } = getBoundingBox(containerRef.current); // We want to track the "true" offset relative to the viewport. // Since we apply -breakoutAmount as a margin to the SELECTED item, // it should not affect the parent container's x coordinate in a standard layout. if (x !== horizontalOffset) { setHorizontalOffset(x); } } }, [terminalWidth, mainAreaWidth, horizontalOffset]); // 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 ( {/* Use conditional coloring instead of conditional rendering */} {showScrollArrows && items.length > maxItemsToShow && ( 0 ? theme.text.primary : theme.text.secondary} > ▲ )} {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.ui.focus; numberColor = theme.ui.focus; } 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, )}.`; const breakoutAmount = isSelected ? Math.max(0, horizontalOffset - 2) : 0; return ( {/* Radio button indicator */} {isSelected ? '●' : ' '} {/* Item number */} {showNumbers && !item.hideNumber && ( {itemNumberText} )} {/* Custom content via render prop */} {renderItem(item, { isSelected, titleColor, numberColor, })} ); })} {showScrollArrows && items.length > maxItemsToShow && ( )} ); }