Invitations screen changes. Scrollable list. Details. And role selection on import

This commit is contained in:
2026-02-09 08:14:52 +00:00
parent df57f1b9ad
commit ef169e76db
10 changed files with 2237 additions and 557 deletions

View File

@@ -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,