import { isDeepStrictEqual } from "node:util"; import React, { type FC, useState, useEffect, useRef, useCallback } from "react"; import type { PaintKeyboardEvent } from "paintcannon"; import arrayToRotated from "to-rotated"; import Indicator, { type Props as IndicatorProps } from "./select-indicator.tsx"; import ItemComponent, { type Props as ItemProps } from "./select-item.tsx"; import { useKeyboard } from "../../hooks/use-keyboard.ts"; import { TerminalFlex } from "../terminal-flex.tsx"; type Props = { /** * Items to display in a list. Each item must be an object and have `label` and `value` props, it may also optionally have a `key` prop. * If no `key` prop is provided, `value` will be used as the item key. */ readonly items?: Array>; /** * Listen to user's input. Useful in case there are multiple input components at the same time and input must be "routed" to a specific component. * * @default true */ readonly isFocused?: boolean; /** * Index of initially-selected item in `items` array. * * @default 0 */ readonly initialIndex?: number; /** * Number of items to display. */ readonly limit?: number; /** * Custom component to override the default indicator component. */ readonly indicatorComponent?: FC; /** * Custom component to override the default item component. */ readonly itemComponent?: FC; /** * Function to call when user selects an item. Item object is passed to that function as an argument. */ readonly onSelect?: (item: Item) => void; /** * Function to call when user highlights an item. Item object is passed to that function as an argument. */ readonly onHighlight?: (item: Item) => void; readonly onKeyDown?: (event: PaintKeyboardEvent) => void; }; export type Item = { key?: string; label: string; value: V; }; function SelectInput({ items = [], isFocused = true, initialIndex = 0, indicatorComponent = Indicator, itemComponent = ItemComponent, limit: customLimit, onSelect, onHighlight, onKeyDown, }: Props) { const hasLimit = typeof customLimit === "number" && items.length > customLimit; const limit = hasLimit ? Math.min(customLimit, items.length) : items.length; const lastIndex = limit - 1; const [rotateIndex, setRotateIndex] = useState( initialIndex > lastIndex ? lastIndex - initialIndex : 0, ); const [selectedIndex, setSelectedIndex] = useState( initialIndex ? (initialIndex > lastIndex ? lastIndex : initialIndex) : 0, ); const previousItems = useRef>>(items); useEffect(() => { if ( !isDeepStrictEqual( previousItems.current.map(item => item.value), items.map(item => item.value), ) ) { setRotateIndex(0); setSelectedIndex(0); } previousItems.current = items; }, [items]); useKeyboard( useCallback( event => { onKeyDown?.(event); if (event.defaultPrevented) return; if (event.key === "k" || event.key === "ArrowUp") { event.preventDefault(); const lastIndex = (hasLimit ? limit : items.length) - 1; const atFirstIndex = selectedIndex === 0; const nextIndex = hasLimit ? selectedIndex : lastIndex; const nextRotateIndex = atFirstIndex ? rotateIndex + 1 : rotateIndex; const nextSelectedIndex = atFirstIndex ? nextIndex : selectedIndex - 1; setRotateIndex(nextRotateIndex); setSelectedIndex(nextSelectedIndex); const slicedItems = hasLimit ? arrayToRotated(items, nextRotateIndex).slice(0, limit) : items; if (typeof onHighlight === "function") { onHighlight(slicedItems[nextSelectedIndex]!); } } if (event.key === "j" || event.key === "ArrowDown") { event.preventDefault(); const atLastIndex = selectedIndex === (hasLimit ? limit : items.length) - 1; const nextIndex = hasLimit ? selectedIndex : 0; const nextRotateIndex = atLastIndex ? rotateIndex - 1 : rotateIndex; const nextSelectedIndex = atLastIndex ? nextIndex : selectedIndex + 1; setRotateIndex(nextRotateIndex); setSelectedIndex(nextSelectedIndex); const slicedItems = hasLimit ? arrayToRotated(items, nextRotateIndex).slice(0, limit) : items; if (typeof onHighlight === "function") { onHighlight(slicedItems[nextSelectedIndex]!); } } // Enable selection directly from number keys. if (/^[1-9]$/.test(event.key)) { const targetIndex = Number.parseInt(event.key, 10) - 1; const visibleItems = hasLimit ? arrayToRotated(items, rotateIndex).slice(0, limit) : items; if (targetIndex >= 0 && targetIndex < visibleItems.length) { event.preventDefault(); const selectedItem = visibleItems[targetIndex]; if (selectedItem) { onSelect?.(selectedItem); } } } if (event.key === "Enter") { event.preventDefault(); const slicedItems = hasLimit ? arrayToRotated(items, rotateIndex).slice(0, limit) : items; if (typeof onSelect === "function") { onSelect(slicedItems[selectedIndex]!); } } }, [hasLimit, limit, rotateIndex, selectedIndex, items, onSelect, onHighlight, onKeyDown], ), isFocused, ); const slicedItems: Item[] = hasLimit ? arrayToRotated(items, rotateIndex).slice(0, limit) : items; return ( {slicedItems.map((item, index) => { const isSelected = index === selectedIndex; return ( {React.createElement(indicatorComponent, { isSelected, })} {React.createElement(itemComponent, { ...item, isSelected, })} ); })} ); } export default SelectInput;