init claude-code
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
export * from './SelectMulti.js'
|
||||
export type { OptionWithDescription } from './select.js'
|
||||
export * from './select.js'
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
|
||||
type OptionMapItem<T> = {
|
||||
label: ReactNode
|
||||
value: T
|
||||
description?: string
|
||||
previous: OptionMapItem<T> | undefined
|
||||
next: OptionMapItem<T> | undefined
|
||||
index: number
|
||||
}
|
||||
|
||||
export default class OptionMap<T> extends Map<T, OptionMapItem<T>> {
|
||||
readonly first: OptionMapItem<T> | undefined
|
||||
readonly last: OptionMapItem<T> | undefined
|
||||
|
||||
constructor(options: OptionWithDescription<T>[]) {
|
||||
const items: Array<[T, OptionMapItem<T>]> = []
|
||||
let firstItem: OptionMapItem<T> | undefined
|
||||
let lastItem: OptionMapItem<T> | undefined
|
||||
let previous: OptionMapItem<T> | undefined
|
||||
let index = 0
|
||||
|
||||
for (const option of options) {
|
||||
const item = {
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
description: option.description,
|
||||
previous,
|
||||
next: undefined,
|
||||
index,
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
previous.next = item
|
||||
}
|
||||
|
||||
firstItem ||= item
|
||||
lastItem = item
|
||||
|
||||
items.push([option.value, item])
|
||||
index++
|
||||
previous = item
|
||||
}
|
||||
|
||||
super(items)
|
||||
this.first = firstItem
|
||||
this.last = lastItem
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { ListItem } from '../design-system/ListItem.js';
|
||||
export type SelectOptionProps = {
|
||||
/**
|
||||
* Determines if option is focused.
|
||||
*/
|
||||
readonly isFocused: boolean;
|
||||
|
||||
/**
|
||||
* Determines if option is selected.
|
||||
*/
|
||||
readonly isSelected: boolean;
|
||||
|
||||
/**
|
||||
* Option label.
|
||||
*/
|
||||
readonly children: ReactNode;
|
||||
|
||||
/**
|
||||
* Optional description to display below the label.
|
||||
*/
|
||||
readonly description?: string;
|
||||
|
||||
/**
|
||||
* Determines if the down arrow should be shown.
|
||||
*/
|
||||
readonly shouldShowDownArrow?: boolean;
|
||||
|
||||
/**
|
||||
* Determines if the up arrow should be shown.
|
||||
*/
|
||||
readonly shouldShowUpArrow?: boolean;
|
||||
|
||||
/**
|
||||
* Whether ListItem should declare the terminal cursor position.
|
||||
* Set false when a child declares its own cursor (e.g. BaseTextInput).
|
||||
*/
|
||||
readonly declareCursor?: boolean;
|
||||
};
|
||||
export function SelectOption(t0) {
|
||||
const $ = _c(8);
|
||||
const {
|
||||
isFocused,
|
||||
isSelected,
|
||||
children,
|
||||
description,
|
||||
shouldShowDownArrow,
|
||||
shouldShowUpArrow,
|
||||
declareCursor
|
||||
} = t0;
|
||||
let t1;
|
||||
if ($[0] !== children || $[1] !== declareCursor || $[2] !== description || $[3] !== isFocused || $[4] !== isSelected || $[5] !== shouldShowDownArrow || $[6] !== shouldShowUpArrow) {
|
||||
t1 = <ListItem isFocused={isFocused} isSelected={isSelected} description={description} showScrollDown={shouldShowDownArrow} showScrollUp={shouldShowUpArrow} styled={false} declareCursor={declareCursor}>{children}</ListItem>;
|
||||
$[0] = children;
|
||||
$[1] = declareCursor;
|
||||
$[2] = description;
|
||||
$[3] = isFocused;
|
||||
$[4] = isSelected;
|
||||
$[5] = shouldShowDownArrow;
|
||||
$[6] = shouldShowUpArrow;
|
||||
$[7] = t1;
|
||||
} else {
|
||||
t1 = $[7];
|
||||
}
|
||||
return t1;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlJlYWN0Tm9kZSIsIkxpc3RJdGVtIiwiU2VsZWN0T3B0aW9uUHJvcHMiLCJpc0ZvY3VzZWQiLCJpc1NlbGVjdGVkIiwiY2hpbGRyZW4iLCJkZXNjcmlwdGlvbiIsInNob3VsZFNob3dEb3duQXJyb3ciLCJzaG91bGRTaG93VXBBcnJvdyIsImRlY2xhcmVDdXJzb3IiLCJTZWxlY3RPcHRpb24iLCJ0MCIsIiQiLCJfYyIsInQxIl0sInNvdXJjZXMiOlsic2VsZWN0LW9wdGlvbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHR5cGUgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBMaXN0SXRlbSB9IGZyb20gJy4uL2Rlc2lnbi1zeXN0ZW0vTGlzdEl0ZW0uanMnXG5cbmV4cG9ydCB0eXBlIFNlbGVjdE9wdGlvblByb3BzID0ge1xuICAvKipcbiAgICogRGV0ZXJtaW5lcyBpZiBvcHRpb24gaXMgZm9jdXNlZC5cbiAgICovXG4gIHJlYWRvbmx5IGlzRm9jdXNlZDogYm9vbGVhblxuXG4gIC8qKlxuICAgKiBEZXRlcm1pbmVzIGlmIG9wdGlvbiBpcyBzZWxlY3RlZC5cbiAgICovXG4gIHJlYWRvbmx5IGlzU2VsZWN0ZWQ6IGJvb2xlYW5cblxuICAvKipcbiAgICogT3B0aW9uIGxhYmVsLlxuICAgKi9cbiAgcmVhZG9ubHkgY2hpbGRyZW46IFJlYWN0Tm9kZVxuXG4gIC8qKlxuICAgKiBPcHRpb25hbCBkZXNjcmlwdGlvbiB0byBkaXNwbGF5IGJlbG93IHRoZSBsYWJlbC5cbiAgICovXG4gIHJlYWRvbmx5IGRlc2NyaXB0aW9uPzogc3RyaW5nXG5cbiAgLyoqXG4gICAqIERldGVybWluZXMgaWYgdGhlIGRvd24gYXJyb3cgc2hvdWxkIGJlIHNob3duLlxuICAgKi9cbiAgcmVhZG9ubHkgc2hvdWxkU2hvd0Rvd25BcnJvdz86IGJvb2xlYW5cblxuICAvKipcbiAgICogRGV0ZXJtaW5lcyBpZiB0aGUgdXAgYXJyb3cgc2hvdWxkIGJlIHNob3duLlxuICAgKi9cbiAgcmVhZG9ubHkgc2hvdWxkU2hvd1VwQXJyb3c/OiBib29sZWFuXG5cbiAgLyoqXG4gICAqIFdoZXRoZXIgTGlzdEl0ZW0gc2hvdWxkIGRlY2xhcmUgdGhlIHRlcm1pbmFsIGN1cnNvciBwb3NpdGlvbi5cbiAgICogU2V0IGZhbHNlIHdoZW4gYSBjaGlsZCBkZWNsYXJlcyBpdHMgb3duIGN1cnNvciAoZS5nLiBCYXNlVGV4dElucHV0KS5cbiAgICovXG4gIHJlYWRvbmx5IGRlY2xhcmVDdXJzb3I/OiBib29sZWFuXG59XG5cbmV4cG9ydCBmdW5jdGlvbiBTZWxlY3RPcHRpb24oe1xuICBpc0ZvY3VzZWQsXG4gIGlzU2VsZWN0ZWQsXG4gIGNoaWxkcmVuLFxuICBkZXNjcmlwdGlvbixcbiAgc2hvdWxkU2hvd0Rvd25BcnJvdyxcbiAgc2hvdWxkU2hvd1VwQXJyb3csXG4gIGRlY2xhcmVDdXJzb3IsXG59OiBTZWxlY3RPcHRpb25Qcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIHJldHVybiAoXG4gICAgPExpc3RJdGVtXG4gICAgICBpc0ZvY3VzZWQ9e2lzRm9jdXNlZH1cbiAgICAgIGlzU2VsZWN0ZWQ9e2lzU2VsZWN0ZWR9XG4gICAgICBkZXNjcmlwdGlvbj17ZGVzY3JpcHRpb259XG4gICAgICBzaG93U2Nyb2xsRG93bj17c2hvdWxkU2hvd0Rvd25BcnJvd31cbiAgICAgIHNob3dTY3JvbGxVcD17c2hvdWxkU2hvd1VwQXJyb3d9XG4gICAgICBzdHlsZWQ9e2ZhbHNlfVxuICAgICAgZGVjbGFyZUN1cnNvcj17ZGVjbGFyZUN1cnNvcn1cbiAgICA+XG4gICAgICB7Y2hpbGRyZW59XG4gICAgPC9MaXN0SXRlbT5cbiAgKVxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsT0FBT0EsS0FBSyxJQUFJLEtBQUtDLFNBQVMsUUFBUSxPQUFPO0FBQzdDLFNBQVNDLFFBQVEsUUFBUSw4QkFBOEI7QUFFdkQsT0FBTyxLQUFLQyxpQkFBaUIsR0FBRztFQUM5QjtBQUNGO0FBQ0E7RUFDRSxTQUFTQyxTQUFTLEVBQUUsT0FBTzs7RUFFM0I7QUFDRjtBQUNBO0VBQ0UsU0FBU0MsVUFBVSxFQUFFLE9BQU87O0VBRTVCO0FBQ0Y7QUFDQTtFQUNFLFNBQVNDLFFBQVEsRUFBRUwsU0FBUzs7RUFFNUI7QUFDRjtBQUNBO0VBQ0UsU0FBU00sV0FBVyxDQUFDLEVBQUUsTUFBTTs7RUFFN0I7QUFDRjtBQUNBO0VBQ0UsU0FBU0MsbUJBQW1CLENBQUMsRUFBRSxPQUFPOztFQUV0QztBQUNGO0FBQ0E7RUFDRSxTQUFTQyxpQkFBaUIsQ0FBQyxFQUFFLE9BQU87O0VBRXBDO0FBQ0Y7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsYUFBYSxDQUFDLEVBQUUsT0FBTztBQUNsQyxDQUFDO0FBRUQsT0FBTyxTQUFBQyxhQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQXNCO0lBQUFWLFNBQUE7SUFBQUMsVUFBQTtJQUFBQyxRQUFBO0lBQUFDLFdBQUE7SUFBQUMsbUJBQUE7SUFBQUMsaUJBQUE7SUFBQUM7RUFBQSxJQUFBRSxFQVFUO0VBQUEsSUFBQUcsRUFBQTtFQUFBLElBQUFGLENBQUEsUUFBQVAsUUFBQSxJQUFBTyxDQUFBLFFBQUFILGFBQUEsSUFBQUcsQ0FBQSxRQUFBTixXQUFBLElBQUFNLENBQUEsUUFBQVQsU0FBQSxJQUFBUyxDQUFBLFFBQUFSLFVBQUEsSUFBQVEsQ0FBQSxRQUFBTCxtQkFBQSxJQUFBSyxDQUFBLFFBQUFKLGlCQUFBO0lBRWhCTSxFQUFBLElBQUMsUUFBUSxDQUNJWCxTQUFTLENBQVRBLFVBQVEsQ0FBQyxDQUNSQyxVQUFVLENBQVZBLFdBQVMsQ0FBQyxDQUNURSxXQUFXLENBQVhBLFlBQVUsQ0FBQyxDQUNSQyxjQUFtQixDQUFuQkEsb0JBQWtCLENBQUMsQ0FDckJDLFlBQWlCLENBQWpCQSxrQkFBZ0IsQ0FBQyxDQUN2QixNQUFLLENBQUwsTUFBSSxDQUFDLENBQ0VDLGFBQWEsQ0FBYkEsY0FBWSxDQUFDLENBRTNCSixTQUFPLENBQ1YsRUFWQyxRQUFRLENBVUU7SUFBQU8sQ0FBQSxNQUFBUCxRQUFBO0lBQUFPLENBQUEsTUFBQUgsYUFBQTtJQUFBRyxDQUFBLE1BQUFOLFdBQUE7SUFBQU0sQ0FBQSxNQUFBVCxTQUFBO0lBQUFTLENBQUEsTUFBQVIsVUFBQTtJQUFBUSxDQUFBLE1BQUFMLG1CQUFBO0lBQUFLLENBQUEsTUFBQUosaUJBQUE7SUFBQUksQ0FBQSxNQUFBRSxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBRixDQUFBO0VBQUE7RUFBQSxPQVZYRSxFQVVXO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,414 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { isDeepStrictEqual } from 'util'
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js'
|
||||
import type { InputEvent } from '../../ink/events/input-event.js'
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw space/arrow multiselect input
|
||||
import { useInput } from '../../ink.js'
|
||||
import {
|
||||
normalizeFullWidthDigits,
|
||||
normalizeFullWidthSpace,
|
||||
} from '../../utils/stringUtils.js'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
import { useSelectNavigation } from './use-select-navigation.js'
|
||||
|
||||
export type UseMultiSelectStateProps<T> = {
|
||||
/**
|
||||
* When disabled, user input is ignored.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
isDisabled?: boolean
|
||||
|
||||
/**
|
||||
* Number of items to display.
|
||||
*
|
||||
* @default 5
|
||||
*/
|
||||
visibleOptionCount?: number
|
||||
|
||||
/**
|
||||
* Options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Initially selected values.
|
||||
*/
|
||||
defaultValue?: T[]
|
||||
|
||||
/**
|
||||
* Callback when selection changes.
|
||||
*/
|
||||
onChange?: (values: T[]) => void
|
||||
|
||||
/**
|
||||
* Callback for canceling the select.
|
||||
*/
|
||||
onCancel: () => void
|
||||
|
||||
/**
|
||||
* Callback for focusing an option.
|
||||
*/
|
||||
onFocus?: (value: T) => void
|
||||
|
||||
/**
|
||||
* Value to focus
|
||||
*/
|
||||
focusValue?: T
|
||||
|
||||
/**
|
||||
* Text for the submit button. When provided, a submit button is shown and
|
||||
* Enter toggles selection (submit only fires when the button is focused).
|
||||
* When omitted, Enter submits directly and Space toggles selection.
|
||||
*/
|
||||
submitButtonText?: string
|
||||
|
||||
/**
|
||||
* Callback when user submits. Receives the currently selected values.
|
||||
*/
|
||||
onSubmit?: (values: T[]) => void
|
||||
|
||||
/**
|
||||
* Callback when user presses down from the last item (submit button).
|
||||
* If provided, navigation will not wrap to the first item.
|
||||
*/
|
||||
onDownFromLastItem?: () => void
|
||||
|
||||
/**
|
||||
* Callback when user presses up from the first item.
|
||||
* If provided, navigation will not wrap to the last item.
|
||||
*/
|
||||
onUpFromFirstItem?: () => void
|
||||
|
||||
/**
|
||||
* Focus the last option initially instead of the first.
|
||||
*/
|
||||
initialFocusLast?: boolean
|
||||
|
||||
/**
|
||||
* When true, numeric keys (1-9) do not toggle options by index.
|
||||
* Mirrors the rendering layer's hideIndexes: if index labels aren't shown,
|
||||
* pressing a number shouldn't silently toggle an invisible mapping.
|
||||
*/
|
||||
hideIndexes?: boolean
|
||||
}
|
||||
|
||||
export type MultiSelectState<T> = {
|
||||
/**
|
||||
* Value of the currently focused option.
|
||||
*/
|
||||
focusedValue: T | undefined
|
||||
|
||||
/**
|
||||
* Index of the first visible option.
|
||||
*/
|
||||
visibleFromIndex: number
|
||||
|
||||
/**
|
||||
* Index of the last visible option.
|
||||
*/
|
||||
visibleToIndex: number
|
||||
|
||||
/**
|
||||
* All options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Visible options.
|
||||
*/
|
||||
visibleOptions: Array<OptionWithDescription<T> & { index: number }>
|
||||
|
||||
/**
|
||||
* Whether the focused option is an input type.
|
||||
*/
|
||||
isInInput: boolean
|
||||
|
||||
/**
|
||||
* Currently selected values.
|
||||
*/
|
||||
selectedValues: T[]
|
||||
|
||||
/**
|
||||
* Current input field values.
|
||||
*/
|
||||
inputValues: Map<T, string>
|
||||
|
||||
/**
|
||||
* Whether the submit button is focused.
|
||||
*/
|
||||
isSubmitFocused: boolean
|
||||
|
||||
/**
|
||||
* Update an input field value.
|
||||
*/
|
||||
updateInputValue: (value: T, inputValue: string) => void
|
||||
|
||||
/**
|
||||
* Callback for canceling the select.
|
||||
*/
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function useMultiSelectState<T>({
|
||||
isDisabled = false,
|
||||
visibleOptionCount = 5,
|
||||
options,
|
||||
defaultValue = [],
|
||||
onChange,
|
||||
onCancel,
|
||||
onFocus,
|
||||
focusValue,
|
||||
submitButtonText,
|
||||
onSubmit,
|
||||
onDownFromLastItem,
|
||||
onUpFromFirstItem,
|
||||
initialFocusLast,
|
||||
hideIndexes = false,
|
||||
}: UseMultiSelectStateProps<T>): MultiSelectState<T> {
|
||||
const [selectedValues, setSelectedValues] = useState<T[]>(defaultValue)
|
||||
const [isSubmitFocused, setIsSubmitFocused] = useState(false)
|
||||
|
||||
// Reset selectedValues when options change (e.g. async-loaded data changes
|
||||
// defaultValue after mount). Mirrors the reset pattern in use-select-navigation.ts
|
||||
// and the deleted ui/useMultiSelectState.ts — without this, MCPServerDesktopImportDialog
|
||||
// keeps colliding servers checked after getAllMcpConfigs() resolves.
|
||||
const [lastOptions, setLastOptions] = useState(options)
|
||||
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
|
||||
setSelectedValues(defaultValue)
|
||||
setLastOptions(options)
|
||||
}
|
||||
|
||||
// State for input type options
|
||||
const [inputValues, setInputValues] = useState<Map<T, string>>(() => {
|
||||
const initialMap = new Map<T, string>()
|
||||
options.forEach(option => {
|
||||
if (option.type === 'input' && option.initialValue) {
|
||||
initialMap.set(option.value, option.initialValue)
|
||||
}
|
||||
})
|
||||
return initialMap
|
||||
})
|
||||
|
||||
const updateSelectedValues = useCallback(
|
||||
(values: T[] | ((prev: T[]) => T[])) => {
|
||||
const newValues =
|
||||
typeof values === 'function' ? values(selectedValues) : values
|
||||
setSelectedValues(newValues)
|
||||
onChange?.(newValues)
|
||||
},
|
||||
[selectedValues, onChange],
|
||||
)
|
||||
|
||||
const navigation = useSelectNavigation<T>({
|
||||
visibleOptionCount,
|
||||
options,
|
||||
initialFocusValue: initialFocusLast
|
||||
? options[options.length - 1]?.value
|
||||
: undefined,
|
||||
onFocus,
|
||||
focusValue,
|
||||
})
|
||||
|
||||
// Automatically register as an overlay.
|
||||
// This ensures CancelRequestHandler won't intercept Escape when the multi-select is active.
|
||||
useRegisterOverlay('multi-select')
|
||||
|
||||
const updateInputValue = useCallback(
|
||||
(value: T, inputValue: string) => {
|
||||
setInputValues(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(value, inputValue)
|
||||
return next
|
||||
})
|
||||
|
||||
// Find the option and call its onChange
|
||||
const option = options.find(opt => opt.value === value)
|
||||
if (option && option.type === 'input') {
|
||||
option.onChange(inputValue)
|
||||
}
|
||||
|
||||
// Update selected values to include/exclude based on input
|
||||
updateSelectedValues(prev => {
|
||||
if (inputValue) {
|
||||
if (!prev.includes(value)) {
|
||||
return [...prev, value]
|
||||
}
|
||||
return prev
|
||||
} else {
|
||||
return prev.filter(v => v !== value)
|
||||
}
|
||||
})
|
||||
},
|
||||
[options, updateSelectedValues],
|
||||
)
|
||||
|
||||
// Handle all keyboard input
|
||||
useInput(
|
||||
(input, key, event: InputEvent) => {
|
||||
const normalizedInput = normalizeFullWidthDigits(input)
|
||||
const focusedOption = options.find(
|
||||
opt => opt.value === navigation.focusedValue,
|
||||
)
|
||||
const isInInput = focusedOption?.type === 'input'
|
||||
|
||||
// When in input field, only allow navigation keys
|
||||
if (isInInput) {
|
||||
const isAllowedKey =
|
||||
key.upArrow ||
|
||||
key.downArrow ||
|
||||
key.escape ||
|
||||
key.tab ||
|
||||
key.return ||
|
||||
(key.ctrl && (input === 'n' || input === 'p' || key.return))
|
||||
if (!isAllowedKey) return
|
||||
}
|
||||
|
||||
const lastOptionValue = options[options.length - 1]?.value
|
||||
|
||||
// Handle Tab to move forward
|
||||
if (key.tab && !key.shift) {
|
||||
if (
|
||||
submitButtonText &&
|
||||
onSubmit &&
|
||||
navigation.focusedValue === lastOptionValue &&
|
||||
!isSubmitFocused
|
||||
) {
|
||||
setIsSubmitFocused(true)
|
||||
} else if (!isSubmitFocused) {
|
||||
navigation.focusNextOption()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Shift+Tab to move backward
|
||||
if (key.tab && key.shift) {
|
||||
if (submitButtonText && onSubmit && isSubmitFocused) {
|
||||
setIsSubmitFocused(false)
|
||||
navigation.focusOption(lastOptionValue)
|
||||
} else {
|
||||
navigation.focusPreviousOption()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle arrow down / Ctrl+N / j
|
||||
if (
|
||||
key.downArrow ||
|
||||
(key.ctrl && input === 'n') ||
|
||||
(!key.ctrl && !key.shift && input === 'j')
|
||||
) {
|
||||
if (isSubmitFocused && onDownFromLastItem) {
|
||||
onDownFromLastItem()
|
||||
} else if (
|
||||
submitButtonText &&
|
||||
onSubmit &&
|
||||
navigation.focusedValue === lastOptionValue &&
|
||||
!isSubmitFocused
|
||||
) {
|
||||
setIsSubmitFocused(true)
|
||||
} else if (
|
||||
!submitButtonText &&
|
||||
onDownFromLastItem &&
|
||||
navigation.focusedValue === lastOptionValue
|
||||
) {
|
||||
// No submit button — exit from the last option
|
||||
onDownFromLastItem()
|
||||
} else if (!isSubmitFocused) {
|
||||
navigation.focusNextOption()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle arrow up / Ctrl+P / k
|
||||
if (
|
||||
key.upArrow ||
|
||||
(key.ctrl && input === 'p') ||
|
||||
(!key.ctrl && !key.shift && input === 'k')
|
||||
) {
|
||||
if (submitButtonText && onSubmit && isSubmitFocused) {
|
||||
setIsSubmitFocused(false)
|
||||
navigation.focusOption(lastOptionValue)
|
||||
} else if (
|
||||
onUpFromFirstItem &&
|
||||
navigation.focusedValue === options[0]?.value
|
||||
) {
|
||||
onUpFromFirstItem()
|
||||
} else {
|
||||
navigation.focusPreviousOption()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle page navigation
|
||||
if (key.pageDown) {
|
||||
navigation.focusNextPage()
|
||||
return
|
||||
}
|
||||
|
||||
if (key.pageUp) {
|
||||
navigation.focusPreviousPage()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Enter or Space for selection/submit
|
||||
if (key.return || normalizeFullWidthSpace(input) === ' ') {
|
||||
// Ctrl+Enter from input field submits
|
||||
if (key.ctrl && key.return && isInInput && onSubmit) {
|
||||
onSubmit(selectedValues)
|
||||
return
|
||||
}
|
||||
|
||||
// Enter on submit button submits
|
||||
if (isSubmitFocused && onSubmit) {
|
||||
onSubmit(selectedValues)
|
||||
return
|
||||
}
|
||||
|
||||
// No submit button: Enter submits directly, Space still toggles
|
||||
if (key.return && !submitButtonText && onSubmit) {
|
||||
onSubmit(selectedValues)
|
||||
return
|
||||
}
|
||||
|
||||
// Enter or Space toggles selection (including for input fields)
|
||||
if (navigation.focusedValue !== undefined) {
|
||||
const newValues = selectedValues.includes(navigation.focusedValue)
|
||||
? selectedValues.filter(v => v !== navigation.focusedValue)
|
||||
: [...selectedValues, navigation.focusedValue]
|
||||
updateSelectedValues(newValues)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle numeric keys (1-9) for direct selection
|
||||
if (!hideIndexes && /^[0-9]+$/.test(normalizedInput)) {
|
||||
const index = parseInt(normalizedInput) - 1
|
||||
if (index >= 0 && index < options.length) {
|
||||
const value = options[index]!.value
|
||||
const newValues = selectedValues.includes(value)
|
||||
? selectedValues.filter(v => v !== value)
|
||||
: [...selectedValues, value]
|
||||
updateSelectedValues(newValues)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Escape
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
},
|
||||
{ isActive: !isDisabled },
|
||||
)
|
||||
|
||||
return {
|
||||
...navigation,
|
||||
selectedValues,
|
||||
inputValues,
|
||||
isSubmitFocused,
|
||||
updateInputValue,
|
||||
onCancel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js'
|
||||
import type { InputEvent } from '../../ink/events/input-event.js'
|
||||
import { useInput } from '../../ink.js'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import {
|
||||
normalizeFullWidthDigits,
|
||||
normalizeFullWidthSpace,
|
||||
} from '../../utils/stringUtils.js'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
import type { SelectState } from './use-select-state.js'
|
||||
|
||||
export type UseSelectProps<T> = {
|
||||
/**
|
||||
* When disabled, user input is ignored.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
isDisabled?: boolean
|
||||
|
||||
/**
|
||||
* When true, prevents selection on Enter or number keys, but allows
|
||||
* scrolling.
|
||||
* When 'numeric', prevents selection on number keys, but allows Enter (and
|
||||
* scrolling).
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
readonly disableSelection?: boolean | 'numeric'
|
||||
|
||||
/**
|
||||
* Select state.
|
||||
*/
|
||||
state: SelectState<T>
|
||||
|
||||
/**
|
||||
* Options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Whether this is a multi-select component.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
isMultiSelect?: boolean
|
||||
|
||||
/**
|
||||
* Callback when user presses up from the first item.
|
||||
* If provided, navigation will not wrap to the last item.
|
||||
*/
|
||||
onUpFromFirstItem?: () => void
|
||||
|
||||
/**
|
||||
* Callback when user presses down from the last item.
|
||||
* If provided, navigation will not wrap to the first item.
|
||||
*/
|
||||
onDownFromLastItem?: () => void
|
||||
|
||||
/**
|
||||
* Callback when input mode should be toggled for an option.
|
||||
* Called when Tab is pressed (to enter or exit input mode).
|
||||
*/
|
||||
onInputModeToggle?: (value: T) => void
|
||||
|
||||
/**
|
||||
* Current input values for input-type options.
|
||||
* Used to determine if number key should submit an empty input option.
|
||||
*/
|
||||
inputValues?: Map<T, string>
|
||||
|
||||
/**
|
||||
* Whether image selection mode is active on the focused input option.
|
||||
* When true, arrow key navigation in useInput is suppressed so that
|
||||
* Attachments keybindings can handle image navigation instead.
|
||||
*/
|
||||
imagesSelected?: boolean
|
||||
|
||||
/**
|
||||
* Callback to attempt entering image selection mode on DOWN arrow.
|
||||
* Returns true if image selection was entered (images exist), false otherwise.
|
||||
*/
|
||||
onEnterImageSelection?: () => boolean
|
||||
}
|
||||
|
||||
export const useSelectInput = <T>({
|
||||
isDisabled = false,
|
||||
disableSelection = false,
|
||||
state,
|
||||
options,
|
||||
isMultiSelect = false,
|
||||
onUpFromFirstItem,
|
||||
onDownFromLastItem,
|
||||
onInputModeToggle,
|
||||
inputValues,
|
||||
imagesSelected = false,
|
||||
onEnterImageSelection,
|
||||
}: UseSelectProps<T>) => {
|
||||
// Automatically register as an overlay when onCancel is provided.
|
||||
// This ensures CancelRequestHandler won't intercept Escape when the select is active.
|
||||
useRegisterOverlay('select', !!state.onCancel)
|
||||
|
||||
// Determine if the focused option is an input type
|
||||
const isInInput = useMemo(() => {
|
||||
const focusedOption = options.find(opt => opt.value === state.focusedValue)
|
||||
return focusedOption?.type === 'input'
|
||||
}, [options, state.focusedValue])
|
||||
|
||||
// Core navigation via keybindings (up/down/enter/escape)
|
||||
// When in input mode, exclude navigation/accept keybindings so that
|
||||
// j/k/enter pass through to the TextInput instead of being intercepted.
|
||||
const keybindingHandlers = useMemo(() => {
|
||||
const handlers: Record<string, () => void> = {}
|
||||
|
||||
if (!isInInput) {
|
||||
handlers['select:next'] = () => {
|
||||
if (onDownFromLastItem) {
|
||||
const lastOption = options[options.length - 1]
|
||||
if (lastOption && state.focusedValue === lastOption.value) {
|
||||
onDownFromLastItem()
|
||||
return
|
||||
}
|
||||
}
|
||||
state.focusNextOption()
|
||||
}
|
||||
handlers['select:previous'] = () => {
|
||||
if (onUpFromFirstItem && state.visibleFromIndex === 0) {
|
||||
const firstOption = options[0]
|
||||
if (firstOption && state.focusedValue === firstOption.value) {
|
||||
onUpFromFirstItem()
|
||||
return
|
||||
}
|
||||
}
|
||||
state.focusPreviousOption()
|
||||
}
|
||||
handlers['select:accept'] = () => {
|
||||
if (disableSelection === true) return
|
||||
if (state.focusedValue === undefined) return
|
||||
|
||||
const focusedOption = options.find(
|
||||
opt => opt.value === state.focusedValue,
|
||||
)
|
||||
if (focusedOption?.disabled === true) return
|
||||
|
||||
state.selectFocusedOption?.()
|
||||
state.onChange?.(state.focusedValue)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.onCancel) {
|
||||
handlers['select:cancel'] = () => {
|
||||
state.onCancel!()
|
||||
}
|
||||
}
|
||||
|
||||
return handlers
|
||||
}, [
|
||||
options,
|
||||
state,
|
||||
onDownFromLastItem,
|
||||
onUpFromFirstItem,
|
||||
isInInput,
|
||||
disableSelection,
|
||||
])
|
||||
|
||||
useKeybindings(keybindingHandlers, {
|
||||
context: 'Select',
|
||||
isActive: !isDisabled,
|
||||
})
|
||||
|
||||
// Remaining keys that stay as useInput: number keys, pageUp/pageDown, tab, space,
|
||||
// and arrow key navigation when in input mode
|
||||
useInput(
|
||||
(input, key, event: InputEvent) => {
|
||||
const normalizedInput = normalizeFullWidthDigits(input)
|
||||
const focusedOption = options.find(
|
||||
opt => opt.value === state.focusedValue,
|
||||
)
|
||||
const currentIsInInput = focusedOption?.type === 'input'
|
||||
|
||||
// Handle Tab key for input mode toggling
|
||||
if (key.tab && onInputModeToggle && state.focusedValue !== undefined) {
|
||||
onInputModeToggle(state.focusedValue)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentIsInInput) {
|
||||
// When in image selection mode, suppress all input handling so
|
||||
// Attachments keybindings can handle navigation/deletion instead
|
||||
if (imagesSelected) return
|
||||
|
||||
// DOWN arrow enters image selection mode if images exist
|
||||
if (key.downArrow && onEnterImageSelection?.()) {
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// Arrow keys still navigate the select even while in input mode
|
||||
if (key.downArrow || (key.ctrl && input === 'n')) {
|
||||
if (onDownFromLastItem) {
|
||||
const lastOption = options[options.length - 1]
|
||||
if (lastOption && state.focusedValue === lastOption.value) {
|
||||
onDownFromLastItem()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
state.focusNextOption()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
if (key.upArrow || (key.ctrl && input === 'p')) {
|
||||
if (onUpFromFirstItem && state.visibleFromIndex === 0) {
|
||||
const firstOption = options[0]
|
||||
if (firstOption && state.focusedValue === firstOption.value) {
|
||||
onUpFromFirstItem()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
state.focusPreviousOption()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// All other keys (including digits) pass through to TextInput.
|
||||
// Digits should type literally into the input rather than select
|
||||
// options — the user has focused a text field and expects typing
|
||||
// to insert characters, not jump to a different option.
|
||||
return
|
||||
}
|
||||
|
||||
if (key.pageDown) {
|
||||
state.focusNextPage()
|
||||
}
|
||||
|
||||
if (key.pageUp) {
|
||||
state.focusPreviousPage()
|
||||
}
|
||||
|
||||
if (disableSelection !== true) {
|
||||
// Space for multi-select toggle
|
||||
if (
|
||||
isMultiSelect &&
|
||||
normalizeFullWidthSpace(input) === ' ' &&
|
||||
state.focusedValue !== undefined
|
||||
) {
|
||||
const isFocusedOptionDisabled = focusedOption?.disabled === true
|
||||
if (!isFocusedOptionDisabled) {
|
||||
state.selectFocusedOption?.()
|
||||
state.onChange?.(state.focusedValue)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
disableSelection !== 'numeric' &&
|
||||
/^[0-9]+$/.test(normalizedInput)
|
||||
) {
|
||||
const index = parseInt(normalizedInput) - 1
|
||||
if (index >= 0 && index < state.options.length) {
|
||||
const selectedOption = state.options[index]!
|
||||
if (selectedOption.disabled === true) {
|
||||
return
|
||||
}
|
||||
if (selectedOption.type === 'input') {
|
||||
const currentValue = inputValues?.get(selectedOption.value) ?? ''
|
||||
if (currentValue.trim()) {
|
||||
// Pre-filled input: auto-submit (user can Tab to edit instead)
|
||||
state.onChange?.(selectedOption.value)
|
||||
return
|
||||
}
|
||||
if (selectedOption.allowEmptySubmitToCancel) {
|
||||
state.onChange?.(selectedOption.value)
|
||||
return
|
||||
}
|
||||
state.focusOption(selectedOption.value)
|
||||
return
|
||||
}
|
||||
state.onChange?.(selectedOption.value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: !isDisabled },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isDeepStrictEqual } from 'util'
|
||||
import OptionMap from './option-map.js'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
|
||||
type State<T> = {
|
||||
/**
|
||||
* Map where key is option's value and value is option's index.
|
||||
*/
|
||||
optionMap: OptionMap<T>
|
||||
|
||||
/**
|
||||
* Number of visible options.
|
||||
*/
|
||||
visibleOptionCount: number
|
||||
|
||||
/**
|
||||
* Value of the currently focused option.
|
||||
*/
|
||||
focusedValue: T | undefined
|
||||
|
||||
/**
|
||||
* Index of the first visible option.
|
||||
*/
|
||||
visibleFromIndex: number
|
||||
|
||||
/**
|
||||
* Index of the last visible option.
|
||||
*/
|
||||
visibleToIndex: number
|
||||
}
|
||||
|
||||
type Action<T> =
|
||||
| FocusNextOptionAction
|
||||
| FocusPreviousOptionAction
|
||||
| FocusNextPageAction
|
||||
| FocusPreviousPageAction
|
||||
| SetFocusAction<T>
|
||||
| ResetAction<T>
|
||||
|
||||
type SetFocusAction<T> = {
|
||||
type: 'set-focus'
|
||||
value: T
|
||||
}
|
||||
|
||||
type FocusNextOptionAction = {
|
||||
type: 'focus-next-option'
|
||||
}
|
||||
|
||||
type FocusPreviousOptionAction = {
|
||||
type: 'focus-previous-option'
|
||||
}
|
||||
|
||||
type FocusNextPageAction = {
|
||||
type: 'focus-next-page'
|
||||
}
|
||||
|
||||
type FocusPreviousPageAction = {
|
||||
type: 'focus-previous-page'
|
||||
}
|
||||
|
||||
type ResetAction<T> = {
|
||||
type: 'reset'
|
||||
state: State<T>
|
||||
}
|
||||
|
||||
const reducer = <T>(state: State<T>, action: Action<T>): State<T> => {
|
||||
switch (action.type) {
|
||||
case 'focus-next-option': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Wrap to first item if at the end
|
||||
const next = item.next || state.optionMap.first
|
||||
|
||||
if (!next) {
|
||||
return state
|
||||
}
|
||||
|
||||
// When wrapping to first, reset viewport to start
|
||||
if (!item.next && next === state.optionMap.first) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
visibleFromIndex: 0,
|
||||
visibleToIndex: state.visibleOptionCount,
|
||||
}
|
||||
}
|
||||
|
||||
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 === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Wrap to last item if at the beginning
|
||||
const previous = item.previous || state.optionMap.last
|
||||
|
||||
if (!previous) {
|
||||
return state
|
||||
}
|
||||
|
||||
// When wrapping to last, reset viewport to end
|
||||
if (!item.previous && previous === state.optionMap.last) {
|
||||
const nextVisibleToIndex = state.optionMap.size
|
||||
const nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
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 'focus-next-page': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Move by a full page (visibleOptionCount items)
|
||||
const targetIndex = Math.min(
|
||||
state.optionMap.size - 1,
|
||||
item.index + state.visibleOptionCount,
|
||||
)
|
||||
|
||||
// Find the item at the target index
|
||||
let targetItem = state.optionMap.first
|
||||
while (targetItem && targetItem.index < targetIndex) {
|
||||
if (targetItem.next) {
|
||||
targetItem = targetItem.next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Update the visible range to include the new focused item
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
targetItem.index + 1,
|
||||
)
|
||||
const nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: targetItem.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'focus-previous-page': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Move by a full page (visibleOptionCount items)
|
||||
const targetIndex = Math.max(0, item.index - state.visibleOptionCount)
|
||||
|
||||
// Find the item at the target index
|
||||
let targetItem = state.optionMap.first
|
||||
while (targetItem && targetItem.index < targetIndex) {
|
||||
if (targetItem.next) {
|
||||
targetItem = targetItem.next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Update the visible range to include the new focused item
|
||||
const nextVisibleFromIndex = Math.max(0, targetItem.index)
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
nextVisibleFromIndex + state.visibleOptionCount,
|
||||
)
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: targetItem.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'reset': {
|
||||
return action.state
|
||||
}
|
||||
|
||||
case 'set-focus': {
|
||||
// Early return if already focused on this value
|
||||
if (state.focusedValue === action.value) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(action.value)
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Check if the item is already in view
|
||||
if (
|
||||
item.index >= state.visibleFromIndex &&
|
||||
item.index < state.visibleToIndex
|
||||
) {
|
||||
// Already visible, just update focus
|
||||
return {
|
||||
...state,
|
||||
focusedValue: action.value,
|
||||
}
|
||||
}
|
||||
|
||||
// Need to scroll to make the item visible
|
||||
// Scroll as little as possible - put item at edge of viewport
|
||||
let nextVisibleFromIndex: number
|
||||
let nextVisibleToIndex: number
|
||||
|
||||
if (item.index < state.visibleFromIndex) {
|
||||
// Item is above viewport - scroll up to put it at the top
|
||||
nextVisibleFromIndex = item.index
|
||||
nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
nextVisibleFromIndex + state.visibleOptionCount,
|
||||
)
|
||||
} else {
|
||||
// Item is below viewport - scroll down to put it at the bottom
|
||||
nextVisibleToIndex = Math.min(state.optionMap.size, item.index + 1)
|
||||
nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: action.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type UseSelectNavigationProps<T> = {
|
||||
/**
|
||||
* Number of items to display.
|
||||
*
|
||||
* @default 5
|
||||
*/
|
||||
visibleOptionCount?: number
|
||||
|
||||
/**
|
||||
* Options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Initially focused option's value.
|
||||
*/
|
||||
initialFocusValue?: T
|
||||
|
||||
/**
|
||||
* Callback for focusing an option.
|
||||
*/
|
||||
onFocus?: (value: T) => void
|
||||
|
||||
/**
|
||||
* Value to focus
|
||||
*/
|
||||
focusValue?: T
|
||||
}
|
||||
|
||||
export type SelectNavigation<T> = {
|
||||
/**
|
||||
* Value of the currently focused option.
|
||||
*/
|
||||
focusedValue: T | undefined
|
||||
|
||||
/**
|
||||
* 1-based index of the focused option in the full list.
|
||||
* Returns 0 if no option is focused.
|
||||
*/
|
||||
focusedIndex: number
|
||||
|
||||
/**
|
||||
* Index of the first visible option.
|
||||
*/
|
||||
visibleFromIndex: number
|
||||
|
||||
/**
|
||||
* Index of the last visible option.
|
||||
*/
|
||||
visibleToIndex: number
|
||||
|
||||
/**
|
||||
* All options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Visible options.
|
||||
*/
|
||||
visibleOptions: Array<OptionWithDescription<T> & { index: number }>
|
||||
|
||||
/**
|
||||
* Whether the focused option is an input type.
|
||||
*/
|
||||
isInInput: boolean
|
||||
|
||||
/**
|
||||
* Focus next option and scroll the list down, if needed.
|
||||
*/
|
||||
focusNextOption: () => void
|
||||
|
||||
/**
|
||||
* Focus previous option and scroll the list up, if needed.
|
||||
*/
|
||||
focusPreviousOption: () => void
|
||||
|
||||
/**
|
||||
* Focus next page and scroll the list down by a page.
|
||||
*/
|
||||
focusNextPage: () => void
|
||||
|
||||
/**
|
||||
* Focus previous page and scroll the list up by a page.
|
||||
*/
|
||||
focusPreviousPage: () => void
|
||||
|
||||
/**
|
||||
* Focus a specific option by value.
|
||||
*/
|
||||
focusOption: (value: T | undefined) => void
|
||||
}
|
||||
|
||||
const createDefaultState = <T>({
|
||||
visibleOptionCount: customVisibleOptionCount,
|
||||
options,
|
||||
initialFocusValue,
|
||||
currentViewport,
|
||||
}: Pick<UseSelectNavigationProps<T>, 'visibleOptionCount' | 'options'> & {
|
||||
initialFocusValue?: T
|
||||
currentViewport?: { visibleFromIndex: number; visibleToIndex: number }
|
||||
}): State<T> => {
|
||||
const visibleOptionCount =
|
||||
typeof customVisibleOptionCount === 'number'
|
||||
? Math.min(customVisibleOptionCount, options.length)
|
||||
: options.length
|
||||
|
||||
const optionMap = new OptionMap<T>(options)
|
||||
const focusedItem =
|
||||
initialFocusValue !== undefined && optionMap.get(initialFocusValue)
|
||||
const focusedValue = focusedItem ? initialFocusValue : optionMap.first?.value
|
||||
|
||||
let visibleFromIndex = 0
|
||||
let visibleToIndex = visibleOptionCount
|
||||
|
||||
// When there's a valid focused item, adjust viewport to show it
|
||||
if (focusedItem) {
|
||||
const focusedIndex = focusedItem.index
|
||||
|
||||
if (currentViewport) {
|
||||
// If focused item is already in the current viewport range, try to preserve it
|
||||
if (
|
||||
focusedIndex >= currentViewport.visibleFromIndex &&
|
||||
focusedIndex < currentViewport.visibleToIndex
|
||||
) {
|
||||
// Keep the same viewport if it's valid
|
||||
visibleFromIndex = currentViewport.visibleFromIndex
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
currentViewport.visibleToIndex,
|
||||
)
|
||||
} else {
|
||||
// Need to adjust viewport to show focused item
|
||||
// Use minimal scrolling - put item at edge of viewport
|
||||
if (focusedIndex < currentViewport.visibleFromIndex) {
|
||||
// Item is above current viewport - scroll up to put it at the top
|
||||
visibleFromIndex = focusedIndex
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
visibleFromIndex + visibleOptionCount,
|
||||
)
|
||||
} else {
|
||||
// Item is below current viewport - scroll down to put it at the bottom
|
||||
visibleToIndex = Math.min(optionMap.size, focusedIndex + 1)
|
||||
visibleFromIndex = Math.max(0, visibleToIndex - visibleOptionCount)
|
||||
}
|
||||
}
|
||||
} else if (focusedIndex >= visibleOptionCount) {
|
||||
// No current viewport but focused item is outside default viewport
|
||||
// Scroll to show the focused item at the bottom of the viewport
|
||||
visibleToIndex = Math.min(optionMap.size, focusedIndex + 1)
|
||||
visibleFromIndex = Math.max(0, visibleToIndex - visibleOptionCount)
|
||||
}
|
||||
|
||||
// Ensure viewport bounds are valid
|
||||
visibleFromIndex = Math.max(
|
||||
0,
|
||||
Math.min(visibleFromIndex, optionMap.size - 1),
|
||||
)
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
Math.max(visibleOptionCount, visibleToIndex),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
optionMap,
|
||||
visibleOptionCount,
|
||||
focusedValue,
|
||||
visibleFromIndex,
|
||||
visibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSelectNavigation<T>({
|
||||
visibleOptionCount = 5,
|
||||
options,
|
||||
initialFocusValue,
|
||||
onFocus,
|
||||
focusValue,
|
||||
}: UseSelectNavigationProps<T>): SelectNavigation<T> {
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer<T>,
|
||||
{
|
||||
visibleOptionCount,
|
||||
options,
|
||||
initialFocusValue: focusValue || initialFocusValue,
|
||||
} as Parameters<typeof createDefaultState<T>>[0],
|
||||
createDefaultState<T>,
|
||||
)
|
||||
|
||||
// Store onFocus in a ref to avoid re-running useEffect when callback changes
|
||||
const onFocusRef = useRef(onFocus)
|
||||
onFocusRef.current = onFocus
|
||||
|
||||
const [lastOptions, setLastOptions] = useState(options)
|
||||
|
||||
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
|
||||
dispatch({
|
||||
type: 'reset',
|
||||
state: createDefaultState({
|
||||
visibleOptionCount,
|
||||
options,
|
||||
initialFocusValue:
|
||||
focusValue ?? state.focusedValue ?? initialFocusValue,
|
||||
currentViewport: {
|
||||
visibleFromIndex: state.visibleFromIndex,
|
||||
visibleToIndex: state.visibleToIndex,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
setLastOptions(options)
|
||||
}
|
||||
|
||||
const focusNextOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-next-option',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusPreviousOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-previous-option',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusNextPage = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-next-page',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusPreviousPage = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-previous-page',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusOption = useCallback((value: T | undefined) => {
|
||||
if (value !== undefined) {
|
||||
dispatch({
|
||||
type: 'set-focus',
|
||||
value,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
return options
|
||||
.map((option, index) => ({
|
||||
...option,
|
||||
index,
|
||||
}))
|
||||
.slice(state.visibleFromIndex, state.visibleToIndex)
|
||||
}, [options, state.visibleFromIndex, state.visibleToIndex])
|
||||
|
||||
// Validate that focusedValue exists in current options.
|
||||
// This handles the case where options change during render but the reset
|
||||
// action hasn't been processed yet - without this, the cursor would disappear
|
||||
// because focusedValue points to an option that no longer exists.
|
||||
const validatedFocusedValue = useMemo(() => {
|
||||
if (state.focusedValue === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const exists = options.some(opt => opt.value === state.focusedValue)
|
||||
if (exists) {
|
||||
return state.focusedValue
|
||||
}
|
||||
// Fall back to first option if focused value doesn't exist
|
||||
return options[0]?.value
|
||||
}, [state.focusedValue, options])
|
||||
|
||||
const isInInput = useMemo(() => {
|
||||
const focusedOption = options.find(
|
||||
opt => opt.value === validatedFocusedValue,
|
||||
)
|
||||
return focusedOption?.type === 'input'
|
||||
}, [validatedFocusedValue, options])
|
||||
|
||||
// Call onFocus with the validated value (what's actually displayed),
|
||||
// not the internal state value which may be stale if options changed.
|
||||
// Use ref to avoid re-running when callback reference changes.
|
||||
useEffect(() => {
|
||||
if (validatedFocusedValue !== undefined) {
|
||||
onFocusRef.current?.(validatedFocusedValue)
|
||||
}
|
||||
}, [validatedFocusedValue])
|
||||
|
||||
// Allow parent to programmatically set focus via focusValue prop
|
||||
useEffect(() => {
|
||||
if (focusValue !== undefined) {
|
||||
dispatch({
|
||||
type: 'set-focus',
|
||||
value: focusValue,
|
||||
})
|
||||
}
|
||||
}, [focusValue])
|
||||
|
||||
// Compute 1-based focused index for scroll position display
|
||||
const focusedIndex = useMemo(() => {
|
||||
if (validatedFocusedValue === undefined) {
|
||||
return 0
|
||||
}
|
||||
const index = options.findIndex(opt => opt.value === validatedFocusedValue)
|
||||
return index >= 0 ? index + 1 : 0
|
||||
}, [validatedFocusedValue, options])
|
||||
|
||||
return {
|
||||
focusedValue: validatedFocusedValue,
|
||||
focusedIndex,
|
||||
visibleFromIndex: state.visibleFromIndex,
|
||||
visibleToIndex: state.visibleToIndex,
|
||||
visibleOptions,
|
||||
isInInput: isInInput ?? false,
|
||||
focusNextOption,
|
||||
focusPreviousOption,
|
||||
focusNextPage,
|
||||
focusPreviousPage,
|
||||
focusOption,
|
||||
options,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
import { useSelectNavigation } from './use-select-navigation.js'
|
||||
|
||||
export type UseSelectStateProps<T> = {
|
||||
/**
|
||||
* Number of items to display.
|
||||
*
|
||||
* @default 5
|
||||
*/
|
||||
visibleOptionCount?: number
|
||||
|
||||
/**
|
||||
* Options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Initially selected option's value.
|
||||
*/
|
||||
defaultValue?: T
|
||||
|
||||
/**
|
||||
* Callback for selecting an option.
|
||||
*/
|
||||
onChange?: (value: T) => void
|
||||
|
||||
/**
|
||||
* Callback for canceling the select.
|
||||
*/
|
||||
onCancel?: () => void
|
||||
|
||||
/**
|
||||
* Callback for focusing an option.
|
||||
*/
|
||||
onFocus?: (value: T) => void
|
||||
|
||||
/**
|
||||
* Value to focus
|
||||
*/
|
||||
focusValue?: T
|
||||
}
|
||||
|
||||
export type SelectState<T> = {
|
||||
/**
|
||||
* Value of the currently focused option.
|
||||
*/
|
||||
focusedValue: T | undefined
|
||||
|
||||
/**
|
||||
* 1-based index of the focused option in the full list.
|
||||
* Returns 0 if no option is focused.
|
||||
*/
|
||||
focusedIndex: number
|
||||
|
||||
/**
|
||||
* Index of the first visible option.
|
||||
*/
|
||||
visibleFromIndex: number
|
||||
|
||||
/**
|
||||
* Index of the last visible option.
|
||||
*/
|
||||
visibleToIndex: number
|
||||
|
||||
/**
|
||||
* Value of the selected option.
|
||||
*/
|
||||
value: T | undefined
|
||||
|
||||
/**
|
||||
* All options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Visible options.
|
||||
*/
|
||||
visibleOptions: Array<OptionWithDescription<T> & { index: number }>
|
||||
|
||||
/**
|
||||
* Whether the focused option is an input type.
|
||||
*/
|
||||
isInInput: boolean
|
||||
|
||||
/**
|
||||
* Focus next option and scroll the list down, if needed.
|
||||
*/
|
||||
focusNextOption: () => void
|
||||
|
||||
/**
|
||||
* Focus previous option and scroll the list up, if needed.
|
||||
*/
|
||||
focusPreviousOption: () => void
|
||||
|
||||
/**
|
||||
* Focus next page and scroll the list down by a page.
|
||||
*/
|
||||
focusNextPage: () => void
|
||||
|
||||
/**
|
||||
* Focus previous page and scroll the list up by a page.
|
||||
*/
|
||||
focusPreviousPage: () => void
|
||||
|
||||
/**
|
||||
* Focus a specific option by value.
|
||||
*/
|
||||
focusOption: (value: T | undefined) => void
|
||||
|
||||
/**
|
||||
* Select currently focused option.
|
||||
*/
|
||||
selectFocusedOption: () => void
|
||||
|
||||
/**
|
||||
* Callback for selecting an option.
|
||||
*/
|
||||
onChange?: (value: T) => void
|
||||
|
||||
/**
|
||||
* Callback for canceling the select.
|
||||
*/
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function useSelectState<T>({
|
||||
visibleOptionCount = 5,
|
||||
options,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onCancel,
|
||||
onFocus,
|
||||
focusValue,
|
||||
}: UseSelectStateProps<T>): SelectState<T> {
|
||||
const [value, setValue] = useState<T | undefined>(defaultValue)
|
||||
|
||||
const navigation = useSelectNavigation<T>({
|
||||
visibleOptionCount,
|
||||
options,
|
||||
initialFocusValue: undefined,
|
||||
onFocus,
|
||||
focusValue,
|
||||
})
|
||||
|
||||
const selectFocusedOption = useCallback(() => {
|
||||
setValue(navigation.focusedValue)
|
||||
}, [navigation.focusedValue])
|
||||
|
||||
return {
|
||||
...navigation,
|
||||
value,
|
||||
selectFocusedOption,
|
||||
onChange,
|
||||
onCancel,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user