Initial Commit
This commit is contained in:
159
src/tui/components/List.tsx
Normal file
159
src/tui/components/List.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Selectable list component with keyboard navigation.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { colors } from '../theme.js';
|
||||
|
||||
/**
|
||||
* List item type.
|
||||
*/
|
||||
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 List component.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple inline list for displaying items without selection.
|
||||
*/
|
||||
interface SimpleListProps {
|
||||
items: string[];
|
||||
label?: string;
|
||||
bullet?: string;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user