/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React, { useMemo, useCallback } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import { useSelectionList } from '../../hooks/useSelectionList.js'; import { TextInput } from './TextInput.js'; import type { TextBuffer } from './text-buffer.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; /** * Generic interface for items in a searchable list. */ export interface GenericListItem { key: string; label: string; description?: string; [key: string]: unknown; } /** * State returned by the search hook. */ export interface SearchListState { filteredItems: T[]; searchBuffer: TextBuffer | undefined; searchQuery: string; setSearchQuery: (query: string) => void; maxLabelWidth: number; } /** * Props for the SearchableList component. */ export interface SearchableListProps { title?: string; items: T[]; onSelect: (item: T) => void; onClose: () => void; searchPlaceholder?: string; /** Custom item renderer */ renderItem?: ( item: T, isActive: boolean, labelWidth: number, ) => React.ReactNode; /** Optional header content */ header?: React.ReactNode; /** Optional footer content */ footer?: (info: { startIndex: number; endIndex: number; totalVisible: number; }) => React.ReactNode; maxItemsToShow?: number; /** Hook to handle search logic */ useSearch: (props: { items: T[]; onSearch?: (query: string) => void; }) => SearchListState; onSearch?: (query: string) => void; /** Whether to reset selection to the top when items change (e.g. after search) */ resetSelectionOnItemsChange?: boolean; } /** * A generic searchable list component with keyboard navigation. */ export function SearchableList({ title, items, onSelect, onClose, searchPlaceholder = 'Search...', renderItem, header, footer, maxItemsToShow = 10, useSearch, onSearch, resetSelectionOnItemsChange = false, }: SearchableListProps): React.JSX.Element { const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ items, onSearch, }); const selectionItems = useMemo( () => filteredItems.map((item) => ({ key: item.key, value: item, })), [filteredItems], ); const handleSelectValue = useCallback( (item: T) => { onSelect(item); }, [onSelect], ); const { activeIndex, setActiveIndex } = useSelectionList({ items: selectionItems, onSelect: handleSelectValue, isFocused: true, showNumbers: false, wrapAround: true, priority: true, }); const [scrollOffsetState, setScrollOffsetState] = React.useState(0); // Compute effective scroll offset during render to avoid visual flicker let scrollOffset = scrollOffsetState; if (activeIndex < scrollOffset) { scrollOffset = activeIndex; } else if (activeIndex >= scrollOffset + maxItemsToShow) { scrollOffset = activeIndex - maxItemsToShow + 1; } const maxScroll = Math.max(0, filteredItems.length - maxItemsToShow); if (scrollOffset > maxScroll) { scrollOffset = maxScroll; } // Update state to match derived value if it changed if (scrollOffsetState !== scrollOffset) { setScrollOffsetState(scrollOffset); } // Reset selection to top when items change if requested const prevItemsRef = React.useRef(filteredItems); React.useLayoutEffect(() => { if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) { setActiveIndex(0); setScrollOffsetState(0); } prevItemsRef.current = filteredItems; }, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]); // Handle global Escape key to close the list useKeypress( (key) => { if (keyMatchers[Command.ESCAPE](key)) { onClose(); return true; } return false; }, { isActive: true }, ); const visibleItems = filteredItems.slice( scrollOffset, scrollOffset + maxItemsToShow, ); const defaultRenderItem = ( item: T, isActive: boolean, labelWidth: number, ) => ( {isActive ? '●' : ''} {item.label.padEnd(labelWidth)} {item.description && ( {item.description} )} ); return ( {title && ( {title} )} {searchBuffer && ( )} {header && {header}} {filteredItems.length === 0 ? ( No items found. ) : ( <> {filteredItems.length > maxItemsToShow && ( )} {visibleItems.map((item, index) => { const isSelected = activeIndex === scrollOffset + index; return ( {renderItem ? renderItem(item, isSelected, maxLabelWidth) : defaultRenderItem(item, isSelected, maxLabelWidth)} ); })} {filteredItems.length > maxItemsToShow && ( )} )} {footer && ( {footer({ startIndex: scrollOffset, endIndex: scrollOffset + visibleItems.length, totalVisible: filteredItems.length, })} )} ); }