Invitations screen changes. Scrollable list. Details. And role selection on import
This commit is contained in:
@@ -1,13 +1,462 @@
|
||||
/**
|
||||
* Selectable list component with keyboard navigation.
|
||||
* 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 from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import { colors } from '../theme.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* List item type.
|
||||
* 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];
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
@@ -23,7 +472,8 @@ export interface ListItem<T = unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the List component.
|
||||
* Props for the legacy List component.
|
||||
* @deprecated Use ScrollableListProps instead
|
||||
*/
|
||||
interface ListProps<T> {
|
||||
/** List items */
|
||||
@@ -46,6 +496,7 @@ interface ListProps<T> {
|
||||
|
||||
/**
|
||||
* Selectable list with keyboard navigation.
|
||||
* @deprecated Use ScrollableList instead
|
||||
*/
|
||||
export function List<T>({
|
||||
items,
|
||||
@@ -132,8 +583,12 @@ export function List<T>({
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SimpleList Component
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Simple inline list for displaying items without selection.
|
||||
* Props for SimpleList component.
|
||||
*/
|
||||
interface SimpleListProps {
|
||||
items: string[];
|
||||
@@ -141,6 +596,9 @@ interface SimpleListProps {
|
||||
bullet?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple inline list for displaying items without selection.
|
||||
*/
|
||||
export function SimpleList({
|
||||
items,
|
||||
label,
|
||||
|
||||
Reference in New Issue
Block a user