Initial commit

This commit is contained in:
daniel nakov
2025-02-25 00:08:45 -05:00
commit 8bf1bb8913
212 changed files with 25983 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import { type Option } from '@inkjs/ui'
import { optionHeaderKey, type OptionHeader } from './select.js'
type OptionMapItem = (Option | OptionHeader) & {
previous: OptionMapItem | undefined
next: OptionMapItem | undefined
index: number
}
export default class OptionMap extends Map<string, OptionMapItem> {
readonly first: OptionMapItem | undefined
constructor(options: (Option | OptionHeader)[]) {
const items: Array<[string, OptionMapItem]> = []
let firstItem: OptionMapItem | undefined
let previous: OptionMapItem | undefined
let index = 0
for (const option of options) {
const item = {
...option,
previous,
next: undefined,
index,
}
if (previous) {
previous.next = item
}
firstItem ||= item
const key = 'value' in option ? option.value : optionHeaderKey(option)
items.push([key, item])
index++
previous = item
}
super(items)
this.first = firstItem
}
}

View File

@@ -0,0 +1,52 @@
import figures from 'figures'
import { Box, Text } from 'ink'
import React, { type ReactNode } from 'react'
import { type Theme } from './theme.js'
import { useComponentTheme } from '@inkjs/ui'
export type SelectOptionProps = {
/**
* Determines if option is focused.
*/
readonly isFocused: boolean
/**
* Determines if option is selected.
*/
readonly isSelected: boolean
/**
* Determines if pointer is shown when selected
*/
readonly smallPointer?: boolean
/**
* Option label.
*/
readonly children: ReactNode
}
export function SelectOption({
isFocused,
isSelected,
smallPointer,
children,
}: SelectOptionProps) {
const { styles } = useComponentTheme<Theme>('Select')
return (
<Box {...styles.option({ isFocused })}>
{isFocused && (
<Text {...styles.focusIndicator()}>
{smallPointer ? figures.triangleDownSmall : figures.pointer}
</Text>
)}
<Text {...styles.label({ isFocused, isSelected })}>{children}</Text>
{isSelected && (
<Text {...styles.selectedIndicator()}>{figures.tick}</Text>
)}
</Box>
)
}

View File

@@ -0,0 +1,143 @@
import { Box, Text } from 'ink'
import React, { type ReactNode } from 'react'
import { SelectOption } from './select-option.js'
import { type Theme } from './theme.js'
import { useSelectState } from './use-select-state.js'
import { useSelect } from './use-select.js'
import { Option, useComponentTheme } from '@inkjs/ui'
export type OptionSubtree = {
/**
* Header to show above sub-options.
*/
readonly header?: string
/**
* Options.
*/
readonly options: (Option | OptionSubtree)[]
}
export type OptionHeader = {
readonly header: string
readonly optionValues: string[]
}
export const optionHeaderKey = (optionHeader: OptionHeader): string =>
`HEADER-${optionHeader.optionValues.join(',')}`
export type SelectProps = {
/**
* When disabled, user input is ignored.
*
* @default false
*/
readonly isDisabled?: boolean
/**
* Number of visible options.
*
* @default 5
*/
readonly visibleOptionCount?: number
/**
* Highlight text in option labels.
*/
readonly highlightText?: string
/**
* Options.
*/
readonly options: (Option | OptionSubtree)[]
/**
* Default value.
*/
readonly defaultValue?: string
/**
* Callback when selected option changes.
*/
readonly onChange?: (value: string) => void
/**
* Callback when focused option changes.
*/
readonly onFocus?: (value: string) => void
/**
* Value to focus
*/
readonly focusValue?: string
}
export function Select({
isDisabled = false,
visibleOptionCount = 5,
highlightText,
options,
defaultValue,
onChange,
onFocus,
focusValue,
}: SelectProps) {
const state = useSelectState({
visibleOptionCount,
options,
defaultValue,
onChange,
onFocus,
focusValue,
})
useSelect({ isDisabled, state })
const { styles } = useComponentTheme<Theme>('Select')
return (
<Box {...styles.container()}>
{state.visibleOptions.map(option => {
const key = 'value' in option ? option.value : optionHeaderKey(option)
const isFocused =
!isDisabled &&
state.focusedValue !== undefined &&
('value' in option
? state.focusedValue === option.value
: option.optionValues.includes(state.focusedValue))
const isSelected =
!!state.value &&
('value' in option
? state.value === option.value
: option.optionValues.includes(state.value))
const smallPointer = 'header' in option
const labelText = 'label' in option ? option.label : option.header
let label: ReactNode = labelText
if (highlightText && labelText.includes(highlightText)) {
const index = labelText.indexOf(highlightText)
label = (
<>
{labelText.slice(0, index)}
<Text {...styles.highlightedText()}>{highlightText}</Text>
{labelText.slice(index + highlightText.length)}
</>
)
}
return (
<SelectOption
key={key}
isFocused={isFocused}
isSelected={isSelected}
smallPointer={smallPointer}
>
{label}
</SelectOption>
)
})}
</Box>
)
}

View File

@@ -0,0 +1,387 @@
import { isDeepStrictEqual } from 'node:util'
import {
useReducer,
type Reducer,
useCallback,
useMemo,
useState,
useEffect,
} from 'react'
import OptionMap from './option-map.js'
import { Option } from '@inkjs/ui'
import type { OptionHeader, OptionSubtree } from './select.js'
type State = {
/**
* Map where key is option's value and value is option's index.
*/
optionMap: OptionMap
/**
* Number of visible options.
*/
visibleOptionCount: number
/**
* Value of the currently focused option.
*/
focusedValue: string | undefined
/**
* Index of the first visible option.
*/
visibleFromIndex: number
/**
* Index of the last visible option.
*/
visibleToIndex: number
/**
* Value of the previously selected option.
*/
previousValue: string | undefined
/**
* Value of the selected option.
*/
value: string | undefined
}
type Action =
| FocusNextOptionAction
| FocusPreviousOptionAction
| SelectFocusedOptionAction
| SetFocusAction
| ResetAction
type SetFocusAction = {
type: 'set-focus'
value: string
}
type FocusNextOptionAction = {
type: 'focus-next-option'
}
type FocusPreviousOptionAction = {
type: 'focus-previous-option'
}
type SelectFocusedOptionAction = {
type: 'select-focused-option'
}
type ResetAction = {
type: 'reset'
state: State
}
const reducer: Reducer<State, Action> = (state, action) => {
switch (action.type) {
case 'focus-next-option': {
if (!state.focusedValue) {
return state
}
const item = state.optionMap.get(state.focusedValue)
if (!item) {
return state
}
let next = item.next
while (next && !('value' in next)) {
// Skip headers
next = next.next
}
if (!next) {
return state
}
const needsToScroll = next.index >= state.visibleToIndex
if (!needsToScroll) {
return {
...state,
focusedValue: next.value,
}
}
const nextVisibleToIndex = Math.min(
state.optionMap.size,
state.visibleToIndex + 1,
)
const nextVisibleFromIndex = nextVisibleToIndex - state.visibleOptionCount
return {
...state,
focusedValue: next.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
}
}
case 'focus-previous-option': {
if (!state.focusedValue) {
return state
}
const item = state.optionMap.get(state.focusedValue)
if (!item) {
return state
}
let previous = item.previous
while (previous && !('value' in previous)) {
// Skip headers
previous = previous.previous
}
if (!previous) {
return state
}
const needsToScroll = previous.index <= state.visibleFromIndex
if (!needsToScroll) {
return {
...state,
focusedValue: previous.value,
}
}
const nextVisibleFromIndex = Math.max(0, state.visibleFromIndex - 1)
const nextVisibleToIndex = nextVisibleFromIndex + state.visibleOptionCount
return {
...state,
focusedValue: previous.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
}
}
case 'select-focused-option': {
return {
...state,
previousValue: state.value,
value: state.focusedValue,
}
}
case 'reset': {
return action.state
}
case 'set-focus': {
return {
...state,
focusedValue: action.value,
}
}
}
}
export type UseSelectStateProps = {
/**
* Number of items to display.
*
* @default 5
*/
visibleOptionCount?: number
/**
* Options.
*/
options: (Option | OptionSubtree)[]
/**
* Initially selected option's value.
*/
defaultValue?: string
/**
* Callback for selecting an option.
*/
onChange?: (value: string) => void
/**
* Callback for focusing an option.
*/
onFocus?: (value: string) => void
/**
* Value to focus
*/
focusValue?: string
}
export type SelectState = Pick<
State,
'focusedValue' | 'visibleFromIndex' | 'visibleToIndex' | 'value'
> & {
/**
* Visible options.
*/
visibleOptions: Array<(Option | OptionHeader) & { index: number }>
/**
* Focus next option and scroll the list down, if needed.
*/
focusNextOption: () => void
/**
* Focus previous option and scroll the list up, if needed.
*/
focusPreviousOption: () => void
/**
* Select currently focused option.
*/
selectFocusedOption: () => void
}
const flattenOptions = (
options: (Option | OptionSubtree)[],
): (Option | OptionHeader)[] =>
options.flatMap(option => {
if ('options' in option) {
const flatSubtree = flattenOptions(option.options)
const optionValues = flatSubtree.flatMap(o =>
'value' in o ? o.value : [],
)
const header =
option.header !== undefined
? [{ header: option.header, optionValues }]
: []
return [...header, ...flatSubtree]
}
return option
})
const createDefaultState = ({
visibleOptionCount: customVisibleOptionCount,
defaultValue,
options,
}: Pick<
UseSelectStateProps,
'visibleOptionCount' | 'defaultValue' | 'options'
>) => {
const flatOptions = flattenOptions(options)
const visibleOptionCount =
typeof customVisibleOptionCount === 'number'
? Math.min(customVisibleOptionCount, flatOptions.length)
: flatOptions.length
const optionMap = new OptionMap(flatOptions)
const firstOption = optionMap.first
const focusedValue =
firstOption && 'value' in firstOption ? firstOption.value : undefined
return {
optionMap,
visibleOptionCount,
focusedValue,
visibleFromIndex: 0,
visibleToIndex: visibleOptionCount,
previousValue: defaultValue,
value: defaultValue,
}
}
export const useSelectState = ({
visibleOptionCount = 5,
options,
defaultValue,
onChange,
onFocus,
focusValue,
}: UseSelectStateProps) => {
const flatOptions = flattenOptions(options)
const [state, dispatch] = useReducer(
reducer,
{ visibleOptionCount, defaultValue, options },
createDefaultState,
)
const [lastOptions, setLastOptions] = useState(flatOptions)
if (
flatOptions !== lastOptions &&
!isDeepStrictEqual(flatOptions, lastOptions)
) {
dispatch({
type: 'reset',
state: createDefaultState({ visibleOptionCount, defaultValue, options }),
})
setLastOptions(flatOptions)
}
const focusNextOption = useCallback(() => {
dispatch({
type: 'focus-next-option',
})
}, [])
const focusPreviousOption = useCallback(() => {
dispatch({
type: 'focus-previous-option',
})
}, [])
const selectFocusedOption = useCallback(() => {
dispatch({
type: 'select-focused-option',
})
}, [])
const visibleOptions = useMemo(() => {
return flatOptions
.map((option, index) => ({
...option,
index,
}))
.slice(state.visibleFromIndex, state.visibleToIndex)
}, [flatOptions, state.visibleFromIndex, state.visibleToIndex])
useEffect(() => {
if (state.value && state.previousValue !== state.value) {
onChange?.(state.value)
}
}, [state.previousValue, state.value, options, onChange])
useEffect(() => {
if (state.focusedValue) {
onFocus?.(state.focusedValue)
}
}, [state.focusedValue, onFocus])
useEffect(() => {
if (focusValue) {
dispatch({
type: 'set-focus',
value: focusValue,
})
}
}, [focusValue])
return {
focusedValue: state.focusedValue,
visibleFromIndex: state.visibleFromIndex,
visibleToIndex: state.visibleToIndex,
value: state.value,
visibleOptions,
focusNextOption,
focusPreviousOption,
selectFocusedOption,
}
}

View File

@@ -0,0 +1,35 @@
import { useInput } from 'ink'
import { type SelectState } from './use-select-state.js'
export type UseSelectProps = {
/**
* When disabled, user input is ignored.
*
* @default false
*/
isDisabled?: boolean
/**
* Select state.
*/
state: SelectState
}
export const useSelect = ({ isDisabled = false, state }: UseSelectProps) => {
useInput(
(_input, key) => {
if (key.downArrow) {
state.focusNextOption()
}
if (key.upArrow) {
state.focusPreviousOption()
}
if (key.return) {
state.selectFocusedOption()
}
},
{ isActive: !isDisabled },
)
}