618 lines
18 KiB
TypeScript
618 lines
18 KiB
TypeScript
/**
|
|
* 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<T = unknown> {
|
|
/** 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<T> {
|
|
/** Array of list items */
|
|
items: ListItemData<T>[];
|
|
/** 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<T>, 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<T>, 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<T>(
|
|
items: ListItemData<T>[],
|
|
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<T>({
|
|
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<T>): 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<T>, index: number) => {
|
|
if (item.hidden) return null;
|
|
|
|
const isSelected = index === selectedIndex;
|
|
const isFocused = focus && isSelected;
|
|
|
|
// Use custom render if provided
|
|
if (renderItem) {
|
|
return (
|
|
<Box key={item.key}>
|
|
{renderItem(item, isSelected, isFocused)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Default rendering
|
|
const itemColor = isFocused
|
|
? colors.focus
|
|
: item.disabled
|
|
? colors.textMuted
|
|
: getColorFromName(item.color);
|
|
|
|
return (
|
|
<Box key={item.key}>
|
|
<Text
|
|
color={itemColor as string}
|
|
bold={isSelected}
|
|
dimColor={item.disabled}
|
|
>
|
|
{isFocused ? '▸ ' : ' '}
|
|
{item.label}
|
|
</Text>
|
|
{item.description && (
|
|
<Text color={colors.textMuted} dimColor> {item.description}</Text>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
// 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 <Text color={colors.textMuted} dimColor>{emptyMessage}</Text>;
|
|
}
|
|
|
|
// 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 (
|
|
<Box flexDirection="column">
|
|
{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 (
|
|
<Box key={group.id} flexDirection="column">
|
|
{/* Group label */}
|
|
{group.label && (
|
|
<Text color={colors.textMuted} bold>{group.label}</Text>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<Box marginY={1}>
|
|
<Text color={colors.textMuted}>────────────────────────</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// No grouping - render with scroll window
|
|
return (
|
|
<Box flexDirection="column">
|
|
{displayItems.map(({ item, idx }) => renderListItem(item, idx))}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const borderColor = focus ? colors.focus : colors.border;
|
|
|
|
const content = (
|
|
<Box flexDirection="column">
|
|
{/* Filter input */}
|
|
{filterable && isFiltering && (
|
|
<Box marginBottom={1}>
|
|
<Text color={colors.info}>Filter: </Text>
|
|
<TextInput
|
|
value={filterText}
|
|
onChange={handleFilterChange}
|
|
onSubmit={handleFilterSubmit}
|
|
placeholder={filterPlaceholder}
|
|
focus={isFiltering}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{/* List content */}
|
|
{renderContent()}
|
|
|
|
{/* Scroll indicator */}
|
|
{showScrollIndicator && visibleCount > maxVisible && (
|
|
<Text color={colors.textMuted} dimColor>
|
|
{scrollWindow.start + 1}-{scrollWindow.end} of {visibleCount}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Filter hint */}
|
|
{filterable && !isFiltering && (
|
|
<Text color={colors.textMuted} dimColor>
|
|
Press '/' to filter
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
{label && <Text color={colors.primary} bold>{label}</Text>}
|
|
{border ? (
|
|
<Box
|
|
borderStyle="single"
|
|
borderColor={borderColor}
|
|
paddingX={1}
|
|
flexDirection="column"
|
|
>
|
|
{content}
|
|
</Box>
|
|
) : content}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Legacy List Component (kept for backward compatibility)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Legacy list item type.
|
|
* @deprecated Use ListItemData instead
|
|
*/
|
|
export interface ListItem<T = unknown> {
|
|
/** 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<T> {
|
|
/** List items */
|
|
items: ListItem<T>[];
|
|
/** Currently selected index */
|
|
selectedIndex: number;
|
|
/** Selection change handler */
|
|
onSelect: (index: number) => void;
|
|
/** Item activation handler (Enter key) */
|
|
onActivate?: (item: ListItem<T>, 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<T>({
|
|
items,
|
|
selectedIndex,
|
|
onSelect,
|
|
onActivate,
|
|
focus = true,
|
|
maxVisible = 10,
|
|
label,
|
|
border = true,
|
|
}: ListProps<T>): 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 = (
|
|
<Box flexDirection="column">
|
|
{visibleItems.map((item, visibleIndex) => {
|
|
const actualIndex = startIndex + visibleIndex;
|
|
const isSelected = actualIndex === selectedIndex;
|
|
|
|
return (
|
|
<Box key={item.key}>
|
|
<Text
|
|
color={item.disabled ? colors.textMuted : isSelected ? colors.bg : colors.text}
|
|
backgroundColor={isSelected ? colors.focus : undefined}
|
|
bold={isSelected}
|
|
dimColor={item.disabled}
|
|
>
|
|
{isSelected ? '▸ ' : ' '}
|
|
{item.label}
|
|
</Text>
|
|
{item.description && (
|
|
<Text color={colors.textMuted} dimColor> - {item.description}</Text>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
{items.length === 0 && (
|
|
<Text color={colors.textMuted} dimColor>No items</Text>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
{label && <Text color={colors.text} bold>{label}</Text>}
|
|
{border ? (
|
|
<Box
|
|
borderStyle="single"
|
|
borderColor={borderColor}
|
|
paddingX={1}
|
|
flexDirection="column"
|
|
>
|
|
{content}
|
|
</Box>
|
|
) : content}
|
|
{items.length > maxVisible && (
|
|
<Text color={colors.textMuted} dimColor>
|
|
{startIndex + 1}-{Math.min(startIndex + maxVisible, items.length)} of {items.length}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 (
|
|
<Box flexDirection="column">
|
|
{label && <Text color={colors.text} bold>{label}</Text>}
|
|
{items.map((item, index) => (
|
|
<Text key={index} color={colors.text}>
|
|
{bullet} {item}
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|