/** * List components with keyboard navigation. * * Provides: * - ScrollableList: Full-featured list with grouping, filtering, and custom rendering * - List: Basic selectable list (legacy, kept for backward compatibility) * - SimpleList: Non-selectable list for display only */ import React, { useState, useMemo, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; import TextInput from './TextInput.js'; import { colors } from '../theme.js'; // ============================================================================= // Types // ============================================================================= /** * Base list item data interface. * Used by ScrollableList for item data. */ export interface ListItemData { /** Unique key for the item */ key: string; /** Display label */ label: string; /** Optional secondary text/description */ description?: string; /** Optional value associated with item */ value?: T; /** Whether item is disabled (can't be activated) */ disabled?: boolean; /** Whether item should be hidden (not rendered, skipped in navigation) */ hidden?: boolean; /** Custom color name for the item (semantic: 'info', 'warning', 'success', 'error', 'muted') */ color?: string; /** Group identifier for grouping items */ group?: string; } /** * Group definition for organizing list items. */ export interface ListGroup { /** Unique group identifier */ id: string; /** Optional header text to display above group */ label?: string; /** Whether to show a separator after this group */ separator?: boolean; } /** * Props for ScrollableList component. */ export interface ScrollableListProps { /** Array of list items */ items: ListItemData[]; /** Currently selected index */ selectedIndex: number; /** Handler called when selection changes */ onSelect: (index: number) => void; /** Handler called when item is activated (Enter key) */ onActivate?: (item: ListItemData, index: number) => void; /** Whether the list is focused for keyboard input */ focus?: boolean; /** Maximum number of visible items (enables scrolling). Default: 10 */ maxVisible?: number; /** Whether to show a border around the list */ border?: boolean; /** Optional label/title for the list */ label?: string; /** Message to show when list is empty */ emptyMessage?: string; /** Group definitions for organizing items */ groups?: ListGroup[]; /** Whether to enable filtering/search */ filterable?: boolean; /** Placeholder text for filter input */ filterPlaceholder?: string; /** Handler called when filter text changes */ onFilterChange?: (filter: string) => void; /** Custom render function for items */ renderItem?: (item: ListItemData, isSelected: boolean, isFocused: boolean) => React.ReactNode; /** Whether to wrap around when navigating past ends */ wrapNavigation?: boolean; /** Whether to show the scroll position indicator (e.g., "1-5 of 10"). Default: true */ showScrollIndicator?: boolean; } // ============================================================================= // Helper Functions // ============================================================================= /** * Map semantic color names to theme colors. */ function getColorFromName(colorName: string | undefined): string { switch (colorName) { case 'info': return colors.info as string; case 'warning': return colors.warning as string; case 'success': return colors.success as string; case 'error': return colors.error as string; case 'muted': return colors.textMuted as string; case 'accent': return colors.accent as string; case 'primary': return colors.primary as string; default: return colors.text as string; } } /** * Find the next valid (non-hidden) index in a direction. * * @param items - Array of items * @param currentIndex - Current index * @param direction - Direction to search (1 for down, -1 for up) * @param wrap - Whether to wrap around at ends * @returns Next valid index, or current if none found */ function findNextValidIndex( items: ListItemData[], currentIndex: number, direction: 1 | -1, wrap: boolean = false ): number { if (items.length === 0) return 0; // Count visible items const visibleIndices = items .map((item, idx) => ({ item, idx })) .filter(({ item }) => !item.hidden) .map(({ idx }) => idx); if (visibleIndices.length === 0) return currentIndex; // Find current position in visible indices const currentVisiblePos = visibleIndices.indexOf(currentIndex); if (currentVisiblePos === -1) { // Current index is hidden, find nearest visible return visibleIndices[0] ?? 0; } // Calculate next position let nextVisiblePos = currentVisiblePos + direction; if (wrap) { if (nextVisiblePos < 0) nextVisiblePos = visibleIndices.length - 1; if (nextVisiblePos >= visibleIndices.length) nextVisiblePos = 0; } else { nextVisiblePos = Math.max(0, Math.min(visibleIndices.length - 1, nextVisiblePos)); } return visibleIndices[nextVisiblePos] ?? 0; } /** * Calculate scroll window for visible items. */ function calculateScrollWindow( selectedIndex: number, totalItems: number, maxVisible: number ): { startIndex: number; endIndex: number } { const halfWindow = Math.floor(maxVisible / 2); let startIndex = Math.max(0, selectedIndex - halfWindow); let endIndex = Math.min(totalItems, startIndex + maxVisible); // Adjust start if we're near the end if (endIndex - startIndex < maxVisible) { startIndex = Math.max(0, endIndex - maxVisible); } return { startIndex, endIndex }; } // ============================================================================= // ScrollableList Component // ============================================================================= /** * Full-featured scrollable list with grouping, filtering, and custom rendering. */ export function ScrollableList({ items, selectedIndex, onSelect, onActivate, focus = true, maxVisible = 10, border = false, label, emptyMessage = 'No items', groups, filterable = false, filterPlaceholder = 'Filter...', onFilterChange, renderItem, wrapNavigation = false, showScrollIndicator = true, }: ScrollableListProps): React.ReactElement { // Filter state const [filterText, setFilterText] = useState(''); const [isFiltering, setIsFiltering] = useState(false); // Filter items based on filter text const filteredItems = useMemo(() => { if (!filterText.trim()) return items; const lowerFilter = filterText.toLowerCase(); return items.map(item => ({ ...item, hidden: item.hidden || !item.label.toLowerCase().includes(lowerFilter), })); }, [items, filterText]); // Get visible (non-hidden) items count const visibleCount = useMemo(() => filteredItems.filter(item => !item.hidden).length, [filteredItems] ); // Handle keyboard navigation useInput((input, key) => { if (!focus) return; // Toggle filter mode with '/' if (filterable && input === '/' && !isFiltering) { setIsFiltering(true); return; } // Exit filter mode with Escape if (isFiltering && key.escape) { setIsFiltering(false); setFilterText(''); onFilterChange?.(''); return; } // Don't process navigation when filtering if (isFiltering) return; // Navigation if (key.upArrow || input === 'k') { const newIndex = findNextValidIndex(filteredItems, selectedIndex, -1, wrapNavigation); onSelect(newIndex); } else if (key.downArrow || input === 'j') { const newIndex = findNextValidIndex(filteredItems, selectedIndex, 1, wrapNavigation); onSelect(newIndex); } else if (key.return && onActivate) { const item = filteredItems[selectedIndex]; if (item && !item.disabled && !item.hidden) { onActivate(item, selectedIndex); } } }, { isActive: focus && !isFiltering }); // Handle filter text change const handleFilterChange = useCallback((value: string) => { setFilterText(value); onFilterChange?.(value); }, [onFilterChange]); // Handle filter submit (Enter in filter mode) const handleFilterSubmit = useCallback(() => { setIsFiltering(false); }, []); // Render a single item const renderListItem = (item: ListItemData, index: number) => { if (item.hidden) return null; const isSelected = index === selectedIndex; const isFocused = focus && isSelected; // Use custom render if provided if (renderItem) { return ( {renderItem(item, isSelected, isFocused)} ); } // Default rendering const itemColor = isFocused ? colors.focus : item.disabled ? colors.textMuted : getColorFromName(item.color); return ( {isFocused ? '▸ ' : ' '} {item.label} {item.description && ( {item.description} )} ); }; // Get all visible (non-hidden) items with their original indices const visibleItemsWithIndices = useMemo(() => { return filteredItems .map((item, idx) => ({ item, idx })) .filter(({ item }) => !item.hidden); }, [filteredItems]); // Calculate scroll window based on visible items only const scrollWindow = useMemo(() => { // Find position of selected index in visible items const selectedVisiblePos = visibleItemsWithIndices.findIndex(({ idx }) => idx === selectedIndex); const effectivePos = selectedVisiblePos >= 0 ? selectedVisiblePos : 0; const halfWindow = Math.floor(maxVisible / 2); let start = Math.max(0, effectivePos - halfWindow); let end = Math.min(visibleItemsWithIndices.length, start + maxVisible); // Adjust start if we're near the end if (end - start < maxVisible) { start = Math.max(0, end - maxVisible); } return { start, end }; }, [visibleItemsWithIndices, selectedIndex, maxVisible]); // Get the slice of visible items to display const displayItems = useMemo(() => { return visibleItemsWithIndices.slice(scrollWindow.start, scrollWindow.end); }, [visibleItemsWithIndices, scrollWindow]); // Render content based on grouping const renderContent = () => { // Show empty message if no visible items if (visibleCount === 0) { return {emptyMessage}; } // If groups are defined, render grouped (but still respect maxVisible) if (groups && groups.length > 0) { // Get display item indices for quick lookup const displayIndices = new Set(displayItems.map(({ idx }) => idx)); return ( {groups.map((group, groupIndex) => { // Filter to only items that are in this group AND in the display window const groupItems = displayItems.filter(({ item }) => item.group === group.id); if (groupItems.length === 0) return null; return ( {/* Group label */} {group.label && ( {group.label} )} {/* Group items */} {groupItems.map(({ item, idx }) => renderListItem(item, idx))} {/* Separator - only show if there are more groups with items after this */} {group.separator && groupIndex < groups.length - 1 && ( ──────────────────────── )} ); })} ); } // No grouping - render with scroll window return ( {displayItems.map(({ item, idx }) => renderListItem(item, idx))} ); }; const borderColor = focus ? colors.focus : colors.border; const content = ( {/* Filter input */} {filterable && isFiltering && ( Filter: )} {/* List content */} {renderContent()} {/* Scroll indicator */} {showScrollIndicator && visibleCount > maxVisible && ( {scrollWindow.start + 1}-{scrollWindow.end} of {visibleCount} )} {/* Filter hint */} {filterable && !isFiltering && ( Press '/' to filter )} ); return ( {label && {label}} {border ? ( {content} ) : content} ); } // ============================================================================= // Legacy List Component (kept for backward compatibility) // ============================================================================= /** * Legacy list item type. * @deprecated Use ListItemData instead */ export interface ListItem { /** Unique key for the item */ key: string; /** Display label */ label: string; /** Optional secondary text */ description?: string; /** Optional value associated with item */ value?: T; /** Whether item is disabled */ disabled?: boolean; } /** * Props for the legacy List component. * @deprecated Use ScrollableListProps instead */ interface ListProps { /** List items */ items: ListItem[]; /** Currently selected index */ selectedIndex: number; /** Selection change handler */ onSelect: (index: number) => void; /** Item activation handler (Enter key) */ onActivate?: (item: ListItem, index: number) => void; /** Whether list is focused */ focus?: boolean; /** Maximum visible items (for scrolling) */ maxVisible?: number; /** Optional label */ label?: string; /** Show border */ border?: boolean; } /** * Selectable list with keyboard navigation. * @deprecated Use ScrollableList instead */ export function List({ items, selectedIndex, onSelect, onActivate, focus = true, maxVisible = 10, label, border = true, }: ListProps): React.ReactElement { // Handle keyboard input useInput((input, key) => { if (!focus) return; if (key.upArrow || input === 'k') { const newIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1; onSelect(newIndex); } else if (key.downArrow || input === 'j') { const newIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0; onSelect(newIndex); } else if (key.return && onActivate && items[selectedIndex]) { const item = items[selectedIndex]; if (item && !item.disabled) { onActivate(item, selectedIndex); } } }, { isActive: focus }); // Calculate visible range for scrolling const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), items.length - maxVisible)); const visibleItems = items.slice(startIndex, startIndex + maxVisible); const borderColor = focus ? colors.focus : colors.border; const content = ( {visibleItems.map((item, visibleIndex) => { const actualIndex = startIndex + visibleIndex; const isSelected = actualIndex === selectedIndex; return ( {isSelected ? '▸ ' : ' '} {item.label} {item.description && ( - {item.description} )} ); })} {items.length === 0 && ( No items )} ); return ( {label && {label}} {border ? ( {content} ) : content} {items.length > maxVisible && ( {startIndex + 1}-{Math.min(startIndex + maxVisible, items.length)} of {items.length} )} ); } // ============================================================================= // SimpleList Component // ============================================================================= /** * Props for SimpleList component. */ interface SimpleListProps { items: string[]; label?: string; bullet?: string; } /** * Simple inline list for displaying items without selection. */ export function SimpleList({ items, label, bullet = '•' }: SimpleListProps): React.ReactElement { return ( {label && {label}} {items.map((item, index) => ( {bullet} {item} ))} ); }