mirror of
https://github.com/Onewon/claude-code.git
synced 2026-04-26 23:01:23 +03:00
Initial commit
This commit is contained in:
42
src/components/CustomSelect/option-map.ts
Normal file
42
src/components/CustomSelect/option-map.ts
Normal 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
|
||||
}
|
||||
}
|
||||
52
src/components/CustomSelect/select-option.tsx
Normal file
52
src/components/CustomSelect/select-option.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
src/components/CustomSelect/select.tsx
Normal file
143
src/components/CustomSelect/select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
387
src/components/CustomSelect/use-select-state.ts
Normal file
387
src/components/CustomSelect/use-select-state.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
35
src/components/CustomSelect/use-select.ts
Normal file
35
src/components/CustomSelect/use-select.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user