feat: build
This commit is contained in:
commit
f90eee85d8
1906 files changed
+514339
No files matched your search
@@ -0,0 +1,213 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
import type { PastedContent } from '../../utils/config.js';
|
||||
import type { ImageDimensions } from '../../utils/imageResizer.js';
|
||||
import type { OptionWithDescription } from './select.js';
|
||||
import { SelectInputOption } from './select-input-option.js';
|
||||
import { SelectOption } from './select-option.js';
|
||||
import { useMultiSelectState } from './use-multi-select-state.js';
|
||||
export type SelectMultiProps<T> = {
|
||||
readonly isDisabled?: boolean;
|
||||
readonly visibleOptionCount?: number;
|
||||
readonly options: OptionWithDescription<T>[];
|
||||
readonly defaultValue?: T[];
|
||||
readonly onCancel: () => void;
|
||||
readonly onChange?: (values: T[]) => void;
|
||||
readonly onFocus?: (value: T) => void;
|
||||
readonly 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.
|
||||
*/
|
||||
readonly submitButtonText?: string;
|
||||
/**
|
||||
* Callback when user submits. Receives the currently selected values.
|
||||
*/
|
||||
readonly onSubmit?: (values: T[]) => void;
|
||||
/**
|
||||
* When true, hides the numeric indexes next to each option.
|
||||
*/
|
||||
readonly hideIndexes?: boolean;
|
||||
/**
|
||||
* Callback when user presses down from the last item (submit button).
|
||||
* If provided, navigation will not wrap to the first item.
|
||||
*/
|
||||
readonly onDownFromLastItem?: () => void;
|
||||
/**
|
||||
* Callback when user presses up from the first item.
|
||||
* If provided, navigation will not wrap to the last item.
|
||||
*/
|
||||
readonly onUpFromFirstItem?: () => void;
|
||||
/**
|
||||
* Focus the last option initially instead of the first.
|
||||
*/
|
||||
readonly initialFocusLast?: boolean;
|
||||
/**
|
||||
* Callback to open external editor for editing input option values.
|
||||
* When provided, ctrl+g will trigger this callback in input options
|
||||
* with the current value and a setter function to update the internal state.
|
||||
*/
|
||||
readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void;
|
||||
readonly onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void;
|
||||
readonly pastedContents?: Record<number, PastedContent>;
|
||||
readonly onRemoveImage?: (id: number) => void;
|
||||
};
|
||||
export function SelectMulti(t0) {
|
||||
const $ = _c(44);
|
||||
const {
|
||||
isDisabled: t1,
|
||||
visibleOptionCount: t2,
|
||||
options,
|
||||
defaultValue: t3,
|
||||
onCancel,
|
||||
onChange,
|
||||
onFocus,
|
||||
focusValue,
|
||||
submitButtonText,
|
||||
onSubmit,
|
||||
onDownFromLastItem,
|
||||
onUpFromFirstItem,
|
||||
initialFocusLast,
|
||||
onOpenEditor,
|
||||
hideIndexes: t4,
|
||||
onImagePaste,
|
||||
pastedContents,
|
||||
onRemoveImage
|
||||
} = t0;
|
||||
const isDisabled = t1 === undefined ? false : t1;
|
||||
const visibleOptionCount = t2 === undefined ? 5 : t2;
|
||||
let t5;
|
||||
if ($[0] !== t3) {
|
||||
t5 = t3 === undefined ? [] : t3;
|
||||
$[0] = t3;
|
||||
$[1] = t5;
|
||||
} else {
|
||||
t5 = $[1];
|
||||
}
|
||||
const defaultValue = t5;
|
||||
const hideIndexes = t4 === undefined ? false : t4;
|
||||
let t6;
|
||||
if ($[2] !== defaultValue || $[3] !== focusValue || $[4] !== hideIndexes || $[5] !== initialFocusLast || $[6] !== isDisabled || $[7] !== onCancel || $[8] !== onChange || $[9] !== onDownFromLastItem || $[10] !== onFocus || $[11] !== onSubmit || $[12] !== onUpFromFirstItem || $[13] !== options || $[14] !== submitButtonText || $[15] !== visibleOptionCount) {
|
||||
t6 = {
|
||||
isDisabled,
|
||||
visibleOptionCount,
|
||||
options,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onCancel,
|
||||
onFocus,
|
||||
focusValue,
|
||||
submitButtonText,
|
||||
onSubmit,
|
||||
onDownFromLastItem,
|
||||
onUpFromFirstItem,
|
||||
initialFocusLast,
|
||||
hideIndexes
|
||||
};
|
||||
$[2] = defaultValue;
|
||||
$[3] = focusValue;
|
||||
$[4] = hideIndexes;
|
||||
$[5] = initialFocusLast;
|
||||
$[6] = isDisabled;
|
||||
$[7] = onCancel;
|
||||
$[8] = onChange;
|
||||
$[9] = onDownFromLastItem;
|
||||
$[10] = onFocus;
|
||||
$[11] = onSubmit;
|
||||
$[12] = onUpFromFirstItem;
|
||||
$[13] = options;
|
||||
$[14] = submitButtonText;
|
||||
$[15] = visibleOptionCount;
|
||||
$[16] = t6;
|
||||
} else {
|
||||
t6 = $[16];
|
||||
}
|
||||
const state = useMultiSelectState(t6);
|
||||
let T0;
|
||||
let T1;
|
||||
let t7;
|
||||
let t8;
|
||||
let t9;
|
||||
if ($[17] !== hideIndexes || $[18] !== isDisabled || $[19] !== onCancel || $[20] !== onImagePaste || $[21] !== onOpenEditor || $[22] !== onRemoveImage || $[23] !== options.length || $[24] !== pastedContents || $[25] !== state) {
|
||||
const maxIndexWidth = options.length.toString().length;
|
||||
T1 = Box;
|
||||
t9 = "column";
|
||||
T0 = Box;
|
||||
t7 = "column";
|
||||
t8 = state.visibleOptions.map((option, index) => {
|
||||
const isOptionFocused = !isDisabled && state.focusedValue === option.value && !state.isSubmitFocused;
|
||||
const isSelected = state.selectedValues.includes(option.value);
|
||||
const isFirstVisibleOption = option.index === state.visibleFromIndex;
|
||||
const isLastVisibleOption = option.index === state.visibleToIndex - 1;
|
||||
const areMoreOptionsBelow = state.visibleToIndex < options.length;
|
||||
const areMoreOptionsAbove = state.visibleFromIndex > 0;
|
||||
const i = state.visibleFromIndex + index + 1;
|
||||
if (option.type === "input") {
|
||||
const inputValue = state.inputValues.get(option.value) || "";
|
||||
return <Box key={String(option.value)} gap={1}><SelectInputOption option={option} isFocused={isOptionFocused} isSelected={false} shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption} shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption} maxIndexWidth={maxIndexWidth} index={i} inputValue={inputValue} onInputChange={value => {
|
||||
state.updateInputValue(option.value, value);
|
||||
}} onSubmit={_temp} onExit={() => {
|
||||
onCancel();
|
||||
}} layout="compact" onOpenEditor={onOpenEditor} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage}><Text color={isSelected ? "success" : undefined}>[{isSelected ? figures.tick : " "}]{" "}</Text></SelectInputOption></Box>;
|
||||
}
|
||||
return <Box key={String(option.value)} gap={1}><SelectOption isFocused={isOptionFocused} isSelected={false} shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption} shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption} description={option.description}>{!hideIndexes && <Text dimColor={true}>{`${i}.`.padEnd(maxIndexWidth)}</Text>}<Text color={isSelected ? "success" : undefined}>[{isSelected ? figures.tick : " "}]</Text><Text color={isOptionFocused ? "suggestion" : undefined}>{option.label}</Text></SelectOption></Box>;
|
||||
});
|
||||
$[17] = hideIndexes;
|
||||
$[18] = isDisabled;
|
||||
$[19] = onCancel;
|
||||
$[20] = onImagePaste;
|
||||
$[21] = onOpenEditor;
|
||||
$[22] = onRemoveImage;
|
||||
$[23] = options.length;
|
||||
$[24] = pastedContents;
|
||||
$[25] = state;
|
||||
$[26] = T0;
|
||||
$[27] = T1;
|
||||
$[28] = t7;
|
||||
$[29] = t8;
|
||||
$[30] = t9;
|
||||
} else {
|
||||
T0 = $[26];
|
||||
T1 = $[27];
|
||||
t7 = $[28];
|
||||
t8 = $[29];
|
||||
t9 = $[30];
|
||||
}
|
||||
let t10;
|
||||
if ($[31] !== T0 || $[32] !== t7 || $[33] !== t8) {
|
||||
t10 = <T0 flexDirection={t7}>{t8}</T0>;
|
||||
$[31] = T0;
|
||||
$[32] = t7;
|
||||
$[33] = t8;
|
||||
$[34] = t10;
|
||||
} else {
|
||||
t10 = $[34];
|
||||
}
|
||||
let t11;
|
||||
if ($[35] !== onSubmit || $[36] !== state.isSubmitFocused || $[37] !== submitButtonText) {
|
||||
t11 = submitButtonText && onSubmit && <Box marginTop={0} gap={1}>{state.isSubmitFocused ? <Text color="suggestion">{figures.pointer}</Text> : <Text> </Text>}<Box marginLeft={3}><Text color={state.isSubmitFocused ? "suggestion" : undefined} bold={true}>{submitButtonText}</Text></Box></Box>;
|
||||
$[35] = onSubmit;
|
||||
$[36] = state.isSubmitFocused;
|
||||
$[37] = submitButtonText;
|
||||
$[38] = t11;
|
||||
} else {
|
||||
t11 = $[38];
|
||||
}
|
||||
let t12;
|
||||
if ($[39] !== T1 || $[40] !== t10 || $[41] !== t11 || $[42] !== t9) {
|
||||
t12 = <T1 flexDirection={t9}>{t10}{t11}</T1>;
|
||||
$[39] = T1;
|
||||
$[40] = t10;
|
||||
$[41] = t11;
|
||||
$[42] = t9;
|
||||
$[43] = t12;
|
||||
} else {
|
||||
t12 = $[43];
|
||||
}
|
||||
return t12;
|
||||
}
|
||||
function _temp() {}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmaWd1cmVzIiwiUmVhY3QiLCJCb3giLCJUZXh0IiwiUGFzdGVkQ29udGVudCIsIkltYWdlRGltZW5zaW9ucyIsIk9wdGlvbldpdGhEZXNjcmlwdGlvbiIsIlNlbGVjdElucHV0T3B0aW9uIiwiU2VsZWN0T3B0aW9uIiwidXNlTXVsdGlTZWxlY3RTdGF0ZSIsIlNlbGVjdE11bHRpUHJvcHMiLCJpc0Rpc2FibGVkIiwidmlzaWJsZU9wdGlvbkNvdW50Iiwib3B0aW9ucyIsIlQiLCJkZWZhdWx0VmFsdWUiLCJvbkNhbmNlbCIsIm9uQ2hhbmdlIiwidmFsdWVzIiwib25Gb2N1cyIsInZhbHVlIiwiZm9jdXNWYWx1ZSIsInN1Ym1pdEJ1dHRvblRleHQiLCJvblN1Ym1pdCIsImhpZGVJbmRleGVzIiwib25Eb3duRnJvbUxhc3RJdGVtIiwib25VcEZyb21GaXJzdEl0ZW0iLCJpbml0aWFsRm9jdXNMYXN0Iiwib25PcGVuRWRpdG9yIiwiY3VycmVudFZhbHVlIiwic2V0VmFsdWUiLCJvbkltYWdlUGFzdGUiLCJiYXNlNjRJbWFnZSIsIm1lZGlhVHlwZSIsImZpbGVuYW1lIiwiZGltZW5zaW9ucyIsInNvdXJjZVBhdGgiLCJwYXN0ZWRDb250ZW50cyIsIlJlY29yZCIsIm9uUmVtb3ZlSW1hZ2UiLCJpZCIsIlNlbGVjdE11bHRpIiwidDAiLCIkIiwiX2MiLCJ0MSIsInQyIiwidDMiLCJ0NCIsInVuZGVmaW5lZCIsInQ1IiwidDYiLCJzdGF0ZSIsIlQwIiwiVDEiLCJ0NyIsInQ4IiwidDkiLCJsZW5ndGgiLCJtYXhJbmRleFdpZHRoIiwidG9TdHJpbmciLCJ2aXNpYmxlT3B0aW9ucyIsIm1hcCIsIm9wdGlvbiIsImluZGV4IiwiaXNPcHRpb25Gb2N1c2VkIiwiZm9jdXNlZFZhbHVlIiwiaXNTdWJtaXRGb2N1c2VkIiwiaXNTZWxlY3RlZCIsInNlbGVjdGVkVmFsdWVzIiwiaW5jbHVkZXMiLCJpc0ZpcnN0VmlzaWJsZU9wdGlvbiIsInZpc2libGVGcm9tSW5kZXgiLCJpc0xhc3RWaXNpYmxlT3B0aW9uIiwidmlzaWJsZVRvSW5kZXgiLCJhcmVNb3JlT3B0aW9uc0JlbG93IiwiYXJlTW9yZU9wdGlvbnNBYm92ZSIsImkiLCJ0eXBlIiwiaW5wdXRWYWx1ZSIsImlucHV0VmFsdWVzIiwiZ2V0IiwiU3RyaW5nIiwidXBkYXRlSW5wdXRWYWx1ZSIsIl90ZW1wIiwidGljayIsImRlc2NyaXB0aW9uIiwicGFkRW5kIiwibGFiZWwiLCJ0MTAiLCJ0MTEiLCJwb2ludGVyIiwidDEyIl0sInNvdXJjZXMiOlsiU2VsZWN0TXVsdGkudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBmaWd1cmVzIGZyb20gJ2ZpZ3VyZXMnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi8uLi9pbmsuanMnXG5pbXBvcnQgdHlwZSB7IFBhc3RlZENvbnRlbnQgfSBmcm9tICcuLi8uLi91dGlscy9jb25maWcuanMnXG5pbXBvcnQgdHlwZSB7IEltYWdlRGltZW5zaW9ucyB9IGZyb20gJy4uLy4uL3V0aWxzL2ltYWdlUmVzaXplci5qcydcbmltcG9ydCB0eXBlIHsgT3B0aW9uV2l0aERlc2NyaXB0aW9uIH0gZnJvbSAnLi9zZWxlY3QuanMnXG5pbXBvcnQgeyBTZWxlY3RJbnB1dE9wdGlvbiB9IGZyb20gJy4vc2VsZWN0LWlucHV0LW9wdGlvbi5qcydcbmltcG9ydCB7IFNlbGVjdE9wdGlvbiB9IGZyb20gJy4vc2VsZWN0LW9wdGlvbi5qcydcbmltcG9ydCB7IHVzZU11bHRpU2VsZWN0U3RhdGUgfSBmcm9tICcuL3VzZS1tdWx0aS1zZWxlY3Qtc3RhdGUuanMnXG5cbmV4cG9ydCB0eXBlIFNlbGVjdE11bHRpUHJvcHM8VD4gPSB7XG4gIHJlYWRvbmx5IGlzRGlzYWJsZWQ/OiBib29sZWFuXG4gIHJlYWRvbmx5IHZpc2libGVPcHRpb25Db3VudD86IG51bWJlclxuICByZWFkb25seSBvcHRpb25zOiBPcHRpb25XaXRoRGVzY3JpcHRpb248VD5bXVxuICByZWFkb25seSBkZWZhdWx0VmFsdWU/OiBUW11cbiAgcmVhZG9ubHkgb25DYW5jZWw6ICgpID0+IHZvaWRcbiAgcmVhZG9ubHkgb25DaGFuZ2U/OiAodmFsdWVzOiBUW10pID0+IHZvaWRcbiAgcmVhZG9ubHkgb25Gb2N1cz86ICh2YWx1ZTogVCkgPT4gdm9pZFxuICByZWFkb25seSBmb2N1c1ZhbHVlPzogVFxuICAvKipcbiAgICogVGV4dCBmb3IgdGhlIHN1Ym1pdCBidXR0b24uIFdoZW4gcHJvdmlkZWQsIGEgc3VibWl0IGJ1dHRvbiBpcyBzaG93biBhbmRcbiAgICogRW50ZXIgdG9nZ2xlcyBzZWxlY3Rpb24gKHN1Ym1pdCBvbmx5IGZpcmVzIHdoZW4gdGhlIGJ1dHRvbiBpcyBmb2N1c2VkKS5cbiAgICogV2hlbiBvbWl0dGVkLCBFbnRlciBzdWJtaXRzIGRpcmVjdGx5IGFuZCBTcGFjZSB0b2dnbGVzIHNlbGVjdGlvbi5cbiAgICovXG4gIHJlYWRvbmx5IHN1Ym1pdEJ1dHRvblRleHQ/OiBzdHJpbmdcbiAgLyoqXG4gICAqIENhbGxiYWNrIHdoZW4gdXNlciBzdWJtaXRzLiBSZWNlaXZlcyB0aGUgY3VycmVudGx5IHNlbGVjdGVkIHZhbHVlcy5cbiAgICovXG4gIHJlYWRvbmx5IG9uU3VibWl0PzogKHZhbHVlczogVFtdKSA9PiB2b2lkXG4gIC8qKlxuICAgKiBXaGVuIHRydWUsIGhpZGVzIHRoZSBudW1lcmljIGluZGV4ZXMgbmV4dCB0byBlYWNoIG9wdGlvbi5cbiAgICovXG4gIHJlYWRvbmx5IGhpZGVJbmRleGVzPzogYm9vbGVhblxuICAvKipcbiAgICogQ2FsbGJhY2sgd2hlbiB1c2VyIHByZXNzZXMgZG93biBmcm9tIHRoZSBsYXN0IGl0ZW0gKHN1Ym1pdCBidXR0b24pLlxuICAgKiBJZiBwcm92aWRlZCwgbmF2aWdhdGlvbiB3aWxsIG5vdCB3cmFwIHRvIHRoZSBmaXJzdCBpdGVtLlxuICAgKi9cbiAgcmVhZG9ubHkgb25Eb3duRnJvbUxhc3RJdGVtPzogKCkgPT4gdm9pZFxuICAvKipcbiAgICogQ2FsbGJhY2sgd2hlbiB1c2VyIHByZXNzZXMgdXAgZnJvbSB0aGUgZmlyc3QgaXRlbS5cbiAgICogSWYgcHJvdmlkZWQsIG5hdmlnYXRpb24gd2lsbCBub3Qgd3JhcCB0byB0aGUgbGFzdCBpdGVtLlxuICAgKi9cbiAgcmVhZG9ubHkgb25VcEZyb21GaXJzdEl0ZW0/OiAoKSA9PiB2b2lkXG4gIC8qKlxuICAgKiBGb2N1cyB0aGUgbGFzdCBvcHRpb24gaW5pdGlhbGx5IGluc3RlYWQgb2YgdGhlIGZpcnN0LlxuICAgKi9cbiAgcmVhZG9ubHkgaW5pdGlhbEZvY3VzTGFzdD86IGJvb2xlYW5cbiAgLyoqXG4gICAqIENhbGxiYWNrIHRvIG9wZW4gZXh0ZXJuYWwgZWRpdG9yIGZvciBlZGl0aW5nIGlucHV0IG9wdGlvbiB2YWx1ZXMuXG4gICAqIFdoZW4gcHJvdmlkZWQsIGN0cmwrZyB3aWxsIHRyaWdnZXIgdGhpcyBjYWxsYmFjayBpbiBpbnB1dCBvcHRpb25zXG4gICAqIHdpdGggdGhlIGN1cnJlbnQgdmFsdWUgYW5kIGEgc2V0dGVyIGZ1bmN0aW9uIHRvIHVwZGF0ZSB0aGUgaW50ZXJuYWwgc3RhdGUuXG4gICAqL1xuICByZWFkb25seSBvbk9wZW5FZGl0b3I/OiAoXG4gICAgY3VycmVudFZhbHVlOiBzdHJpbmcsXG4gICAgc2V0VmFsdWU6ICh2YWx1ZTogc3RyaW5nKSA9PiB2b2lkLFxuICApID0+IHZvaWRcbiAgcmVhZG9ubHkgb25JbWFnZVBhc3RlPzogKFxuICAgIGJhc2U2NEltYWdlOiBzdHJpbmcsXG4gICAgbWVkaWFUeXBlPzogc3RyaW5nLFxuICAgIGZpbGVuYW1lPzogc3RyaW5nLFxuICAgIGRpbWVuc2lvbnM/OiBJbWFnZURpbWVuc2lvbnMsXG4gICAgc291cmNlUGF0aD86IHN0cmluZyxcbiAgKSA9PiB2b2lkXG4gIHJlYWRvbmx5IHBhc3RlZENvbnRlbnRzPzogUmVjb3JkPG51bWJlciwgUGFzdGVkQ29udGVudD5cbiAgcmVhZG9ubHkgb25SZW1vdmVJbWFnZT86IChpZDogbnVtYmVyKSA9PiB2b2lkXG59XG5cbmV4cG9ydCBmdW5jdGlvbiBTZWxlY3RNdWx0aTxUPih7XG4gIGlzRGlzYWJsZWQgPSBmYWxzZSxcbiAgdmlzaWJsZU9wdGlvbkNvdW5
Line truncated
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- UP arrow exit not in Attachments bindings
|
||||
import { Box, Text, useInput } from '../../ink.js';
|
||||
import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import type { PastedContent } from '../../utils/config.js';
|
||||
import { getImageFromClipboard } from '../../utils/imagePaste.js';
|
||||
import type { ImageDimensions } from '../../utils/imageResizer.js';
|
||||
import { ClickableImageRef } from '../ClickableImageRef.js';
|
||||
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
|
||||
import { Byline } from '../design-system/Byline.js';
|
||||
import TextInput from '../TextInput.js';
|
||||
import type { OptionWithDescription } from './select.js';
|
||||
import { SelectOption } from './select-option.js';
|
||||
type Props<T> = {
|
||||
option: Extract<OptionWithDescription<T>, {
|
||||
type: 'input';
|
||||
}>;
|
||||
isFocused: boolean;
|
||||
isSelected: boolean;
|
||||
shouldShowDownArrow: boolean;
|
||||
shouldShowUpArrow: boolean;
|
||||
maxIndexWidth: number;
|
||||
index: number;
|
||||
inputValue: string;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onExit?: () => void;
|
||||
layout: 'compact' | 'expanded';
|
||||
children?: ReactNode;
|
||||
/**
|
||||
* When true, shows the label before the input field.
|
||||
* When false (default), uses the label as the placeholder.
|
||||
*/
|
||||
showLabel?: boolean;
|
||||
/**
|
||||
* Callback to open external editor for editing the input value.
|
||||
* When provided, ctrl+g will trigger this callback with the current value
|
||||
* and a setter function to update the internal state.
|
||||
*/
|
||||
onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void;
|
||||
/**
|
||||
* When true, automatically reset cursor to end of line when:
|
||||
* - Option becomes focused
|
||||
* - Input value changes
|
||||
* This prevents cursor position bugs when the input value updates asynchronously.
|
||||
*/
|
||||
resetCursorOnUpdate?: boolean;
|
||||
/**
|
||||
* Optional callback when an image is pasted into the input.
|
||||
*/
|
||||
onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void;
|
||||
/**
|
||||
* Pasted content to display inline above the input when focused.
|
||||
*/
|
||||
pastedContents?: Record<number, PastedContent>;
|
||||
/**
|
||||
* Callback to remove a pasted image by its ID.
|
||||
*/
|
||||
onRemoveImage?: (id: number) => void;
|
||||
/**
|
||||
* Whether image selection mode is active.
|
||||
*/
|
||||
imagesSelected?: boolean;
|
||||
/**
|
||||
* Currently selected image index within the image attachments array.
|
||||
*/
|
||||
selectedImageIndex?: number;
|
||||
/**
|
||||
* Callback to set image selection mode on/off.
|
||||
*/
|
||||
onImagesSelectedChange?: (selected: boolean) => void;
|
||||
/**
|
||||
* Callback to change the selected image index.
|
||||
*/
|
||||
onSelectedImageIndexChange?: (index: number) => void;
|
||||
};
|
||||
export function SelectInputOption(t0) {
|
||||
const $ = _c(100);
|
||||
const {
|
||||
option,
|
||||
isFocused,
|
||||
isSelected,
|
||||
shouldShowDownArrow,
|
||||
shouldShowUpArrow,
|
||||
maxIndexWidth,
|
||||
index,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
onExit,
|
||||
layout,
|
||||
children,
|
||||
showLabel: t1,
|
||||
onOpenEditor,
|
||||
resetCursorOnUpdate: t2,
|
||||
onImagePaste,
|
||||
pastedContents,
|
||||
onRemoveImage,
|
||||
imagesSelected,
|
||||
selectedImageIndex: t3,
|
||||
onImagesSelectedChange,
|
||||
onSelectedImageIndexChange
|
||||
} = t0;
|
||||
const showLabelProp = t1 === undefined ? false : t1;
|
||||
const resetCursorOnUpdate = t2 === undefined ? false : t2;
|
||||
const selectedImageIndex = t3 === undefined ? 0 : t3;
|
||||
let t4;
|
||||
if ($[0] !== pastedContents) {
|
||||
t4 = pastedContents ? Object.values(pastedContents).filter(_temp) : [];
|
||||
$[0] = pastedContents;
|
||||
$[1] = t4;
|
||||
} else {
|
||||
t4 = $[1];
|
||||
}
|
||||
const imageAttachments = t4;
|
||||
const showLabel = showLabelProp || option.showLabelWithValue === true;
|
||||
const [cursorOffset, setCursorOffset] = useState(inputValue.length);
|
||||
const isUserEditing = useRef(false);
|
||||
let t5;
|
||||
if ($[2] !== inputValue.length || $[3] !== isFocused || $[4] !== resetCursorOnUpdate) {
|
||||
t5 = () => {
|
||||
if (resetCursorOnUpdate && isFocused) {
|
||||
if (isUserEditing.current) {
|
||||
isUserEditing.current = false;
|
||||
} else {
|
||||
setCursorOffset(inputValue.length);
|
||||
}
|
||||
}
|
||||
};
|
||||
$[2] = inputValue.length;
|
||||
$[3] = isFocused;
|
||||
$[4] = resetCursorOnUpdate;
|
||||
$[5] = t5;
|
||||
} else {
|
||||
t5 = $[5];
|
||||
}
|
||||
let t6;
|
||||
if ($[6] !== inputValue || $[7] !== isFocused || $[8] !== resetCursorOnUpdate) {
|
||||
t6 = [resetCursorOnUpdate, isFocused, inputValue];
|
||||
$[6] = inputValue;
|
||||
$[7] = isFocused;
|
||||
$[8] = resetCursorOnUpdate;
|
||||
$[9] = t6;
|
||||
} else {
|
||||
t6 = $[9];
|
||||
}
|
||||
useEffect(t5, t6);
|
||||
let t7;
|
||||
if ($[10] !== inputValue || $[11] !== onInputChange || $[12] !== onOpenEditor) {
|
||||
t7 = () => {
|
||||
onOpenEditor?.(inputValue, onInputChange);
|
||||
};
|
||||
$[10] = inputValue;
|
||||
$[11] = onInputChange;
|
||||
$[12] = onOpenEditor;
|
||||
$[13] = t7;
|
||||
} else {
|
||||
t7 = $[13];
|
||||
}
|
||||
const t8 = isFocused && !!onOpenEditor;
|
||||
let t9;
|
||||
if ($[14] !== t8) {
|
||||
t9 = {
|
||||
context: "Chat",
|
||||
isActive: t8
|
||||
};
|
||||
$[14] = t8;
|
||||
$[15] = t9;
|
||||
} else {
|
||||
t9 = $[15];
|
||||
}
|
||||
useKeybinding("chat:externalEditor", t7, t9);
|
||||
let t10;
|
||||
if ($[16] !== onImagePaste) {
|
||||
t10 = () => {
|
||||
if (!onImagePaste) {
|
||||
return;
|
||||
}
|
||||
getImageFromClipboard().then(imageData => {
|
||||
if (imageData) {
|
||||
onImagePaste(imageData.base64, imageData.mediaType, undefined, imageData.dimensions);
|
||||
}
|
||||
});
|
||||
};
|
||||
$[16] = onImagePaste;
|
||||
$[17] = t10;
|
||||
} else {
|
||||
t10 = $[17];
|
||||
}
|
||||
const t11 = isFocused && !!onImagePaste;
|
||||
let t12;
|
||||
if ($[18] !== t11) {
|
||||
t12 = {
|
||||
context: "Chat",
|
||||
isActive: t11
|
||||
};
|
||||
$[18] = t11;
|
||||
$[19] = t12;
|
||||
} else {
|
||||
t12 = $[19];
|
||||
}
|
||||
useKeybinding("chat:imagePaste", t10, t12);
|
||||
let t13;
|
||||
if ($[20] !== imageAttachments || $[21] !== onRemoveImage) {
|
||||
t13 = () => {
|
||||
if (imageAttachments.length > 0 && onRemoveImage) {
|
||||
onRemoveImage(imageAttachments.at(-1).id);
|
||||
}
|
||||
};
|
||||
$[20] = imageAttachments;
|
||||
$[21] = onRemoveImage;
|
||||
$[22] = t13;
|
||||
} else {
|
||||
t13 = $[22];
|
||||
}
|
||||
const t14 = isFocused && !imagesSelected && inputValue === "" && imageAttachments.length > 0 && !!onRemoveImage;
|
||||
let t15;
|
||||
if ($[23] !== t14) {
|
||||
t15 = {
|
||||
context: "Attachments",
|
||||
isActive: t14
|
||||
};
|
||||
$[23] = t14;
|
||||
$[24] = t15;
|
||||
} else {
|
||||
t15 = $[24];
|
||||
}
|
||||
useKeybinding("attachments:remove", t13, t15);
|
||||
let t16;
|
||||
let t17;
|
||||
if ($[25] !== imageAttachments.length || $[26] !== onSelectedImageIndexChange || $[27] !== selectedImageIndex) {
|
||||
t16 = () => {
|
||||
if (imageAttachments.length > 1) {
|
||||
onSelectedImageIndexChange?.((selectedImageIndex + 1) % imageAttachments.length);
|
||||
}
|
||||
};
|
||||
t17 = () => {
|
||||
if (imageAttachments.length > 1) {
|
||||
onSelectedImageIndexChange?.((selectedImageIndex - 1 + imageAttachments.length) % imageAttachments.length);
|
||||
}
|
||||
};
|
||||
$[25] = imageAttachments.length;
|
||||
$[26] = onSelectedImageIndexChange;
|
||||
$[27] = selectedImageIndex;
|
||||
$[28] = t16;
|
||||
$[29] = t17;
|
||||
} else {
|
||||
t16 = $[28];
|
||||
t17 = $[29];
|
||||
}
|
||||
let t18;
|
||||
if ($[30] !== imageAttachments || $[31] !== onImagesSelectedChange || $[32] !== onRemoveImage || $[33] !== onSelectedImageIndexChange || $[34] !== selectedImageIndex) {
|
||||
t18 = () => {
|
||||
const img = imageAttachments[selectedImageIndex];
|
||||
if (img && onRemoveImage) {
|
||||
onRemoveImage(img.id);
|
||||
if (imageAttachments.length <= 1) {
|
||||
onImagesSelectedChange?.(false);
|
||||
} else {
|
||||
onSelectedImageIndexChange?.(Math.min(selectedImageIndex, imageAttachments.length - 2));
|
||||
}
|
||||
}
|
||||
};
|
||||
$[30] = imageAttachments;
|
||||
$[31] = onImagesSelectedChange;
|
||||
$[32] = onRemoveImage;
|
||||
$[33] = onSelectedImageIndexChange;
|
||||
$[34] = selectedImageIndex;
|
||||
$[35] = t18;
|
||||
} else {
|
||||
t18 = $[35];
|
||||
}
|
||||
let t19;
|
||||
if ($[36] !== onImagesSelectedChange) {
|
||||
t19 = () => {
|
||||
onImagesSelectedChange?.(false);
|
||||
};
|
||||
$[36] = onImagesSelectedChange;
|
||||
$[37] = t19;
|
||||
} else {
|
||||
t19 = $[37];
|
||||
}
|
||||
let t20;
|
||||
if ($[38] !== t16 || $[39] !== t17 || $[40] !== t18 || $[41] !== t19) {
|
||||
t20 = {
|
||||
"attachments:next": t16,
|
||||
"attachments:previous": t17,
|
||||
"attachments:remove": t18,
|
||||
"attachments:exit": t19
|
||||
};
|
||||
$[38] = t16;
|
||||
$[39] = t17;
|
||||
$[40] = t18;
|
||||
$[41] = t19;
|
||||
$[42] = t20;
|
||||
} else {
|
||||
t20 = $[42];
|
||||
}
|
||||
const t21 = isFocused && !!imagesSelected;
|
||||
let t22;
|
||||
if ($[43] !== t21) {
|
||||
t22 = {
|
||||
context: "Attachments",
|
||||
isActive: t21
|
||||
};
|
||||
$[43] = t21;
|
||||
$[44] = t22;
|
||||
} else {
|
||||
t22 = $[44];
|
||||
}
|
||||
useKeybindings(t20, t22);
|
||||
let t23;
|
||||
if ($[45] !== onImagesSelectedChange) {
|
||||
t23 = (_input, key) => {
|
||||
if (key.upArrow) {
|
||||
onImagesSelectedChange?.(false);
|
||||
}
|
||||
};
|
||||
$[45] = onImagesSelectedChange;
|
||||
$[46] = t23;
|
||||
} else {
|
||||
t23 = $[46];
|
||||
}
|
||||
const t24 = isFocused && !!imagesSelected;
|
||||
let t25;
|
||||
if ($[47] !== t24) {
|
||||
t25 = {
|
||||
isActive: t24
|
||||
};
|
||||
$[47] = t24;
|
||||
$[48] = t25;
|
||||
} else {
|
||||
t25 = $[48];
|
||||
}
|
||||
useInput(t23, t25);
|
||||
let t26;
|
||||
let t27;
|
||||
if ($[49] !== imagesSelected || $[50] !== isFocused || $[51] !== onImagesSelectedChange) {
|
||||
t26 = () => {
|
||||
if (!isFocused && imagesSelected) {
|
||||
onImagesSelectedChange?.(false);
|
||||
}
|
||||
};
|
||||
t27 = [isFocused, imagesSelected, onImagesSelectedChange];
|
||||
$[49] = imagesSelected;
|
||||
$[50] = isFocused;
|
||||
$[51] = onImagesSelectedChange;
|
||||
$[52] = t26;
|
||||
$[53] = t27;
|
||||
} else {
|
||||
t26 = $[52];
|
||||
t27 = $[53];
|
||||
}
|
||||
useEffect(t26, t27);
|
||||
const descriptionPaddingLeft = layout === "expanded" ? maxIndexWidth + 3 : maxIndexWidth + 4;
|
||||
const t28 = layout === "compact" ? 0 : undefined;
|
||||
const t29 = `${index}.`;
|
||||
let t30;
|
||||
if ($[54] !== maxIndexWidth || $[55] !== t29) {
|
||||
t30 = t29.padEnd(maxIndexWidth + 2);
|
||||
$[54] = maxIndexWidth;
|
||||
$[55] = t29;
|
||||
$[56] = t30;
|
||||
} else {
|
||||
t30 = $[56];
|
||||
}
|
||||
let t31;
|
||||
if ($[57] !== t30) {
|
||||
t31 = <Text dimColor={true}>{t30}</Text>;
|
||||
$[57] = t30;
|
||||
$[58] = t31;
|
||||
} else {
|
||||
t31 = $[58];
|
||||
}
|
||||
let t32;
|
||||
if ($[59] !== cursorOffset || $[60] !== imagesSelected || $[61] !== inputValue || $[62] !== isFocused || $[63] !== onExit || $[64] !== onImagePaste || $[65] !== onInputChange || $[66] !== onSubmit || $[67] !== option || $[68] !== showLabel) {
|
||||
t32 = showLabel ? <><Text color={isFocused ? "suggestion" : undefined}>{option.label}</Text>{isFocused ? <><Text color="suggestion">{option.labelValueSeparator ?? ", "}</Text><TextInput value={inputValue} onChange={value => {
|
||||
isUserEditing.current = true;
|
||||
onInputChange(value);
|
||||
option.onChange(value);
|
||||
}} onSubmit={onSubmit} onExit={onExit} placeholder={option.placeholder} focus={!imagesSelected} showCursor={true} multiline={true} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={80} onImagePaste={onImagePaste} onPaste={pastedText => {
|
||||
isUserEditing.current = true;
|
||||
const before = inputValue.slice(0, cursorOffset);
|
||||
const after = inputValue.slice(cursorOffset);
|
||||
const newValue = before + pastedText + after;
|
||||
onInputChange(newValue);
|
||||
option.onChange(newValue);
|
||||
setCursorOffset(before.length + pastedText.length);
|
||||
}} /></> : inputValue && <Text>{option.labelValueSeparator ?? ", "}{inputValue}</Text>}</> : isFocused ? <TextInput value={inputValue} onChange={value_0 => {
|
||||
isUserEditing.current = true;
|
||||
onInputChange(value_0);
|
||||
option.onChange(value_0);
|
||||
}} onSubmit={onSubmit} onExit={onExit} placeholder={option.placeholder || (typeof option.label === "string" ? option.label : undefined)} focus={!imagesSelected} showCursor={true} multiline={true} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={80} onImagePaste={onImagePaste} onPaste={pastedText_0 => {
|
||||
isUserEditing.current = true;
|
||||
const before_0 = inputValue.slice(0, cursorOffset);
|
||||
const after_0 = inputValue.slice(cursorOffset);
|
||||
const newValue_0 = before_0 + pastedText_0 + after_0;
|
||||
onInputChange(newValue_0);
|
||||
option.onChange(newValue_0);
|
||||
setCursorOffset(before_0.length + pastedText_0.length);
|
||||
}} /> : <Text color={inputValue ? undefined : "inactive"}>{inputValue || option.placeholder || option.label}</Text>;
|
||||
$[59] = cursorOffset;
|
||||
$[60] = imagesSelected;
|
||||
$[61] = inputValue;
|
||||
$[62] = isFocused;
|
||||
$[63] = onExit;
|
||||
$[64] = onImagePaste;
|
||||
$[65] = onInputChange;
|
||||
$[66] = onSubmit;
|
||||
$[67] = option;
|
||||
$[68] = showLabel;
|
||||
$[69] = t32;
|
||||
} else {
|
||||
t32 = $[69];
|
||||
}
|
||||
let t33;
|
||||
if ($[70] !== children || $[71] !== t28 || $[72] !== t31 || $[73] !== t32) {
|
||||
t33 = <Box flexDirection="row" flexShrink={t28}>{t31}{children}{t32}</Box>;
|
||||
$[70] = children;
|
||||
$[71] = t28;
|
||||
$[72] = t31;
|
||||
$[73] = t32;
|
||||
$[74] = t33;
|
||||
} else {
|
||||
t33 = $[74];
|
||||
}
|
||||
let t34;
|
||||
if ($[75] !== isFocused || $[76] !== isSelected || $[77] !== shouldShowDownArrow || $[78] !== shouldShowUpArrow || $[79] !== t33) {
|
||||
t34 = <SelectOption isFocused={isFocused} isSelected={isSelected} shouldShowDownArrow={shouldShowDownArrow} shouldShowUpArrow={shouldShowUpArrow} declareCursor={false}>{t33}</SelectOption>;
|
||||
$[75] = isFocused;
|
||||
$[76] = isSelected;
|
||||
$[77] = shouldShowDownArrow;
|
||||
$[78] = shouldShowUpArrow;
|
||||
$[79] = t33;
|
||||
$[80] = t34;
|
||||
} else {
|
||||
t34 = $[80];
|
||||
}
|
||||
let t35;
|
||||
if ($[81] !== descriptionPaddingLeft || $[82] !== isFocused || $[83] !== isSelected || $[84] !== option.description || $[85] !== option.dimDescription) {
|
||||
t35 = option.description && <Box paddingLeft={descriptionPaddingLeft}><Text dimColor={option.dimDescription !== false} color={isSelected ? "success" : isFocused ? "suggestion" : undefined}>{option.description}</Text></Box>;
|
||||
$[81] = descriptionPaddingLeft;
|
||||
$[82] = isFocused;
|
||||
$[83] = isSelected;
|
||||
$[84] = option.description;
|
||||
$[85] = option.dimDescription;
|
||||
$[86] = t35;
|
||||
} else {
|
||||
t35 = $[86];
|
||||
}
|
||||
let t36;
|
||||
if ($[87] !== descriptionPaddingLeft || $[88] !== imageAttachments || $[89] !== imagesSelected || $[90] !== isFocused || $[91] !== selectedImageIndex) {
|
||||
t36 = imageAttachments.length > 0 && <Box flexDirection="row" gap={1} paddingLeft={descriptionPaddingLeft}>{imageAttachments.map((img_0, idx) => <ClickableImageRef key={img_0.id} imageId={img_0.id} isSelected={!!imagesSelected && idx === selectedImageIndex} />)}<Box flexGrow={1} justifyContent="flex-start" flexDirection="row"><Text dimColor={true}>{imagesSelected ? <Byline>{imageAttachments.length > 1 && <><ConfigurableShortcutHint action="attachments:next" context="Attachments" fallback={"\u2192"} description="next" /><ConfigurableShortcutHint action="attachments:previous" context="Attachments" fallback={"\u2190"} description="prev" /></>}<ConfigurableShortcutHint action="attachments:remove" context="Attachments" fallback="backspace" description="remove" /><ConfigurableShortcutHint action="attachments:exit" context="Attachments" fallback="esc" description="cancel" /></Byline> : isFocused ? "(\u2193 to select)" : null}</Text></Box></Box>;
|
||||
$[87] = descriptionPaddingLeft;
|
||||
$[88] = imageAttachments;
|
||||
$[89] = imagesSelected;
|
||||
$[90] = isFocused;
|
||||
$[91] = selectedImageIndex;
|
||||
$[92] = t36;
|
||||
} else {
|
||||
t36 = $[92];
|
||||
}
|
||||
let t37;
|
||||
if ($[93] !== layout) {
|
||||
t37 = layout === "expanded" && <Text> </Text>;
|
||||
$[93] = layout;
|
||||
$[94] = t37;
|
||||
} else {
|
||||
t37 = $[94];
|
||||
}
|
||||
let t38;
|
||||
if ($[95] !== t34 || $[96] !== t35 || $[97] !== t36 || $[98] !== t37) {
|
||||
t38 = <Box flexDirection="column" flexShrink={0}>{t34}{t35}{t36}{t37}</Box>;
|
||||
$[95] = t34;
|
||||
$[96] = t35;
|
||||
$[97] = t36;
|
||||
$[98] = t37;
|
||||
$[99] = t38;
|
||||
} else {
|
||||
t38 = $[99];
|
||||
}
|
||||
return t38;
|
||||
}
|
||||
function _temp(c) {
|
||||
return c.type === "image";
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlJlYWN0Tm9kZSIsInVzZUVmZmVjdCIsInVzZVJlZiIsInVzZVN0YXRlIiwiQm94IiwiVGV4dCIsInVzZUlucHV0IiwidXNlS2V5YmluZGluZyIsInVzZUtleWJpbmRpbmdzIiwiUGFzdGVkQ29udGVudCIsImdldEltYWdlRnJvbUNsaXBib2FyZCIsIkltYWdlRGltZW5zaW9ucyIsIkNsaWNrYWJsZUltYWdlUmVmIiwiQ29uZmlndXJhYmxlU2hvcnRjdXRIaW50IiwiQnlsaW5lIiwiVGV4dElucHV0IiwiT3B0aW9uV2l0aERlc2NyaXB0aW9uIiwiU2VsZWN0T3B0aW9uIiwiUHJvcHMiLCJvcHRpb24iLCJFeHRyYWN0IiwiVCIsInR5cGUiLCJpc0ZvY3VzZWQiLCJpc1NlbGVjdGVkIiwic2hvdWxkU2hvd0Rvd25BcnJvdyIsInNob3VsZFNob3dVcEFycm93IiwibWF4SW5kZXhXaWR0aCIsImluZGV4IiwiaW5wdXRWYWx1ZSIsIm9uSW5wdXRDaGFuZ2UiLCJ2YWx1ZSIsIm9uU3VibWl0Iiwib25FeGl0IiwibGF5b3V0IiwiY2hpbGRyZW4iLCJzaG93TGFiZWwiLCJvbk9wZW5FZGl0b3IiLCJjdXJyZW50VmFsdWUiLCJzZXRWYWx1ZSIsInJlc2V0Q3Vyc29yT25VcGRhdGUiLCJvbkltYWdlUGFzdGUiLCJiYXNlNjRJbWFnZSIsIm1lZGlhVHlwZSIsImZpbGVuYW1lIiwiZGltZW5zaW9ucyIsInNvdXJjZVBhdGgiLCJwYXN0ZWRDb250ZW50cyIsIlJlY29yZCIsIm9uUmVtb3ZlSW1hZ2UiLCJpZCIsImltYWdlc1NlbGVjdGVkIiwic2VsZWN0ZWRJbWFnZUluZGV4Iiwib25JbWFnZXNTZWxlY3RlZENoYW5nZSIsInNlbGVjdGVkIiwib25TZWxlY3RlZEltYWdlSW5kZXhDaGFuZ2UiLCJTZWxlY3RJbnB1dE9wdGlvbiIsInQwIiwiJCIsIl9jIiwidDEiLCJ0MiIsInQzIiwic2hvd0xhYmVsUHJvcCIsInVuZGVmaW5lZCIsInQ0IiwiT2JqZWN0IiwidmFsdWVzIiwiZmlsdGVyIiwiX3RlbXAiLCJpbWFnZUF0dGFjaG1lbnRzIiwic2hvd0xhYmVsV2l0aFZhbHVlIiwiY3Vyc29yT2Zmc2V0Iiwic2V0Q3Vyc29yT2Zmc2V0IiwibGVuZ3RoIiwiaXNVc2VyRWRpdGluZyIsInQ1IiwiY3VycmVudCIsInQ2IiwidDciLCJ0OCIsInQ5IiwiY29udGV4dCIsImlzQWN0aXZlIiwidDEwIiwidGhlbiIsImltYWdlRGF0YSIsImJhc2U2NCIsInQxMSIsInQxMiIsInQxMyIsImF0IiwidDE0IiwidDE1IiwidDE2IiwidDE3IiwidDE4IiwiaW1nIiwiTWF0aCIsIm1pbiIsInQxOSIsInQyMCIsInQyMSIsInQyMiIsInQyMyIsIl9pbnB1dCIsImtleSIsInVwQXJyb3ciLCJ0MjQiLCJ0MjUiLCJ0MjYiLCJ0MjciLCJkZXNjcmlwdGlvblBhZGRpbmdMZWZ0IiwidDI4IiwidDI5IiwidDMwIiwicGFkRW5kIiwidDMxIiwidDMyIiwibGFiZWwiLCJsYWJlbFZhbHVlU2VwYXJhdG9yIiwib25DaGFuZ2UiLCJwbGFjZWhvbGRlciIsInBhc3RlZFRleHQiLCJiZWZvcmUiLCJzbGljZSIsImFmdGVyIiwibmV3VmFsdWUiLCJ2YWx1ZV8wIiwicGFzdGVkVGV4dF8wIiwiYmVmb3JlXzAiLCJhZnRlcl8wIiwibmV3VmFsdWVfMCIsInQzMyIsInQzNCIsInQzNSIsImRlc2NyaXB0aW9uIiwiZGltRGVzY3JpcHRpb24iLCJ0MzYiLCJtYXAiLCJpbWdfMCIsImlkeCIsInQzNyIsInQzOCIsImMiXSwic291cmNlcyI6WyJzZWxlY3QtaW5wdXQtb3B0aW9uLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHsgdHlwZSBSZWFjdE5vZGUsIHVzZUVmZmVjdCwgdXNlUmVmLCB1c2VTdGF0ZSB9IGZyb20gJ3JlYWN0J1xuLy8gZXNsaW50LWRpc2FibGUtbmV4dC1saW5lIGN1c3RvbS1ydWxlcy9wcmVmZXItdXNlLWtleWJpbmRpbmdzIC0tIFVQIGFycm93IGV4aXQgbm90IGluIEF0dGFjaG1lbnRzIGJpbmRpbmdzXG5pbXBvcnQgeyBCb3gsIFRleHQsIHVzZUlucHV0IH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHtcbiAgdXNlS2V5YmluZGluZyxcbiAgdXNlS2V5YmluZGluZ3MsXG59IGZyb20gJy4uLy4uL2tleWJpbmRpbmdzL3VzZUtleWJpbmRpbmcuanMnXG5pbXBvcnQgdHlwZSB7IFBhc3RlZENvbnRlbnQgfSBmcm9tICcuLi8uLi91dGlscy9jb25maWcuanMnXG5pbXBvcnQgeyBnZXRJbWFnZUZyb21DbGlwYm9hcmQgfSBmcm9tICcuLi8uLi91dGlscy9pbWFnZVBhc3RlLmpzJ1xuaW1wb3J0IHR5cGUgeyBJbWFnZURpbWVuc2lvbnMgfSBmcm9tICcuLi8uLi91dGlscy9pbWFnZVJlc2l6ZXIuanMnXG5pbXBvcnQgeyBDbGlja2FibGVJbWFnZVJlZiB9IGZyb20gJy4uL0NsaWNrYWJsZUltYWdlUmVmLmpzJ1xuaW1wb3J0IHsgQ29uZmlndXJhYmxlU2hvcnRjdXRIaW50IH0gZnJvbSAnLi4vQ29uZmlndXJhYmxlU2hvcnRjdXRIaW50LmpzJ1xuaW1wb3J0IHsgQnlsaW5lIH0gZnJvbSAnLi4vZGVzaWduLXN5c3RlbS9CeWxpbmUuanMnXG5pbXBvcnQgVGV4dElucHV0IGZyb20gJy4uL1RleHRJbnB1dC5qcydcbmltcG9ydCB0eXBlIHsgT3B0aW9uV2l0aERlc2NyaXB0aW9uIH0gZnJvbSAnLi9zZWxlY3QuanMnXG5pbXBvcnQgeyBTZWxlY3RPcHRpb24gfSBmcm9tICcuL3NlbGVjdC1vcHRpb24uanMnXG5cbnR5cGUgUHJvcHM8VD4gPSB7XG4gIG9wdGlvbjogRXh0cmFjdDxPcHRpb25XaXRoRGVzY3JpcHRpb248VD4sIHsgdHlwZTogJ2lucHV0JyB9PlxuICBpc0ZvY3VzZWQ6IGJvb2xlYW5cbiAgaXNTZWxlY3RlZDogYm9vbGVhblxuICBzaG91bGRTaG93RG93bkFycm93OiBib29sZWFuXG4gIHNob3VsZFNob3dVcEFycm93OiBib29sZWFuXG4gIG1heEluZGV4V2lkdGg6IG51bWJlclxuICBpbmRleDogbnVtYmVyXG4gIGlucHV0VmFsdWU6IHN0cmluZ1xuICBvbklucHV0Q2hhbmdlOiAodmFsdWU6IHN0cmluZykgPT4gdm9pZFxuICBvblN1Ym1pdDogKHZhbHVlOiBzdHJpbmcpID0+IHZvaWRcbiAgb25FeGl0PzogKCkgPT4gdm9pZFxuICBsYXlvdXQ6ICdjb21wYWN0JyB8ICdleHBhbmRlZCdcbiAgY2hpbGRyZW4/OiBSZWFjdE5vZGVcbiAgLyoqXG4gICAqIFdoZW4gdHJ1ZSwgc2hvd3MgdGhlIGxhYmVsIGJlZm9yZSB0aGUgaW5wdXQgZmllbGQuXG4gICAqIFdoZW4gZmFsc2UgKGRlZmF1bHQpLCB1c2VzIHRoZSBsYWJlbCBhcyB0aGUgcGxhY2Vob2xkZXIuXG4gICAqL1xuICBzaG93TGFiZWw/OiBib29sZWFuXG4gIC8qKlxuICAgKiBDYWxsYmFjayB0byBvcGVuIGV4dGVybmFsIGVkaXRvciBmb3IgZWRpdGluZyB0aGUgaW5wdXQgdmFsdWUuXG4gICAqIFdoZW4gcHJvdmlkZWQsIGN0cmwrZyB3aWxsIHRyaWdnZXIgdGhpcyBjYWxsYmFjayB3aXRoIHRoZSBjdXJyZW50IHZhbHVlXG4gICAqIGFuZCBhIHNldHRlciBmdW5jdGlvbiB0byB1cGRhdGUgdGhlIGludGVybmFsIHN0YXRlLlxuICAgKi9cbiAgb25PcGVuRWRpdG9yPzogKFxuICAgIGN1cnJlbnRWYWx1ZTogc3RyaW5nLFxuICAgIHNldFZhbHVlOiAodmFsdWU6IHN0cmluZykgPT4gdm9pZCxcbiAgKSA9PiB2b2lkXG4gIC8qKlxuICAgKiBXaGVuIHRydWUsIGF1dG9tYXRpY2FsbHkgcmVzZXQgY3Vyc29yIHRvIGVuZCBvZiBsaW5lIHdoZW46XG4gICAqIC0gT3B0aW9uIGJlY29tZXMgZm9jdXNlZFxuICAgKiAtIElucHV0IHZhbHVlIGNoYW5nZXNcbiAgICogVGhpcyBwcmV2ZW50cyBjdXJzb3IgcG9zaXRpb24gYnVncyB3aGVuIHRoZSBpbnB1dCB2YWx1ZSB1cGRhdGVzIGFzeW5jaHJvbm91c2x5LlxuICAgKi9cbiAgcmVzZXRDdXJzb3JPblVwZGF0ZT86IGJvb2xlYW5cbiAgLyoqXG4gICAqIE9wdGlvbmFsIGNhbGxiYWNrIHdoZW4gYW4gaW1hZ2UgaXMgcGFzdGV
Line truncated
|
||||
@@ -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=
|
||||
@@ -0,0 +1,690 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import figures from 'figures';
|
||||
import React, { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useDeclaredCursor } from '../../ink/hooks/use-declared-cursor.js';
|
||||
import { stringWidth } from '../../ink/stringWidth.js';
|
||||
import { Ansi, Box, Text } from '../../ink.js';
|
||||
import { count } from '../../utils/array.js';
|
||||
import type { PastedContent } from '../../utils/config.js';
|
||||
import type { ImageDimensions } from '../../utils/imageResizer.js';
|
||||
import { SelectInputOption } from './select-input-option.js';
|
||||
import { SelectOption } from './select-option.js';
|
||||
import { useSelectInput } from './use-select-input.js';
|
||||
import { useSelectState } from './use-select-state.js';
|
||||
|
||||
// Extract text content from ReactNode for width calculation
|
||||
function getTextContent(node: ReactNode): string {
|
||||
if (typeof node === 'string') return node;
|
||||
if (typeof node === 'number') return String(node);
|
||||
if (!node) return '';
|
||||
if (Array.isArray(node)) return node.map(getTextContent).join('');
|
||||
if (React.isValidElement<{
|
||||
children?: ReactNode;
|
||||
}>(node)) {
|
||||
return getTextContent(node.props.children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
type BaseOption<T> = {
|
||||
description?: string;
|
||||
dimDescription?: boolean;
|
||||
label: ReactNode;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export type OptionWithDescription<T = string> = (BaseOption<T> & {
|
||||
type?: 'text';
|
||||
}) | (BaseOption<T> & {
|
||||
type: 'input';
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
/**
|
||||
* Controls behavior when submitting with empty input:
|
||||
* - true: calls onChange (treats empty as valid submission)
|
||||
* - false (default): calls onCancel (treats empty as cancellation)
|
||||
*
|
||||
* Also affects initial Enter press: when true, submits immediately;
|
||||
* when false, enters input mode first so user can type.
|
||||
*/
|
||||
allowEmptySubmitToCancel?: boolean;
|
||||
/**
|
||||
* When true, always shows the label alongside the input value, regardless of
|
||||
* the global inlineDescriptions/showLabel setting. Use this when the label
|
||||
* provides important context that should always be visible (e.g., "Yes, and allow...").
|
||||
*/
|
||||
showLabelWithValue?: boolean;
|
||||
/**
|
||||
* Custom separator between label and value when showLabel is true.
|
||||
* Defaults to ", ". Use ": " for labels that read better with a colon.
|
||||
*/
|
||||
labelValueSeparator?: string;
|
||||
/**
|
||||
* When true, automatically reset cursor to end of line when:
|
||||
* - Option becomes focused
|
||||
* - Input value changes
|
||||
* This prevents cursor position bugs when the input value updates asynchronously.
|
||||
*/
|
||||
resetCursorOnUpdate?: boolean;
|
||||
});
|
||||
export type SelectProps<T> = {
|
||||
/**
|
||||
* When disabled, user input is ignored.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
readonly isDisabled?: boolean;
|
||||
|
||||
/**
|
||||
* When true, prevents selection on Enter but allows scrolling.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
readonly disableSelection?: boolean;
|
||||
|
||||
/**
|
||||
* When true, hides the numeric indexes next to each option.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
readonly hideIndexes?: boolean;
|
||||
|
||||
/**
|
||||
* Number of visible options.
|
||||
*
|
||||
* @default 5
|
||||
*/
|
||||
readonly visibleOptionCount?: number;
|
||||
|
||||
/**
|
||||
* Highlight text in option labels.
|
||||
*/
|
||||
readonly highlightText?: string;
|
||||
|
||||
/**
|
||||
* Options.
|
||||
*/
|
||||
readonly options: OptionWithDescription<T>[];
|
||||
|
||||
/**
|
||||
* Default value.
|
||||
*/
|
||||
readonly defaultValue?: T;
|
||||
|
||||
/**
|
||||
* Callback when cancel is pressed.
|
||||
*/
|
||||
readonly onCancel?: () => void;
|
||||
|
||||
/**
|
||||
* Callback when selected option changes.
|
||||
*/
|
||||
readonly onChange?: (value: T) => void;
|
||||
|
||||
/**
|
||||
* Callback when focused option changes.
|
||||
* Note: This is for one-way notification only. Avoid combining with focusValue
|
||||
* for bidirectional sync, as this can cause feedback loops.
|
||||
*/
|
||||
readonly onFocus?: (value: T) => void;
|
||||
|
||||
/**
|
||||
* Initial value to focus. This is used to set focus when the component mounts.
|
||||
*/
|
||||
readonly defaultFocusValue?: T;
|
||||
|
||||
/**
|
||||
* Layout of the options.
|
||||
* - `compact` (default) tries to use one line per option
|
||||
* - `expanded` uses multiple lines and an empty line between options
|
||||
* - `compact-vertical` uses compact index formatting with descriptions below labels
|
||||
*/
|
||||
readonly layout?: 'compact' | 'expanded' | 'compact-vertical';
|
||||
|
||||
/**
|
||||
* When true, descriptions are rendered inline after the label instead of
|
||||
* in a separate column. Use this for short descriptions like hints.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
readonly inlineDescriptions?: boolean;
|
||||
|
||||
/**
|
||||
* Callback when user presses up from the first item.
|
||||
* If provided, navigation will not wrap to the last item.
|
||||
*/
|
||||
readonly onUpFromFirstItem?: () => void;
|
||||
|
||||
/**
|
||||
* Callback when user presses down from the last item.
|
||||
* If provided, navigation will not wrap to the first item.
|
||||
*/
|
||||
readonly onDownFromLastItem?: () => void;
|
||||
|
||||
/**
|
||||
* Callback when input mode should be toggled for an option.
|
||||
* Called when Tab is pressed (to enter or exit input mode).
|
||||
*/
|
||||
readonly onInputModeToggle?: (value: T) => void;
|
||||
|
||||
/**
|
||||
* Callback to open external editor for editing input option values.
|
||||
* When provided, ctrl+g will trigger this callback in input options
|
||||
* with the current value and a setter function to update the internal state.
|
||||
*/
|
||||
readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void;
|
||||
|
||||
/**
|
||||
* Optional callback when an image is pasted into an input option.
|
||||
*/
|
||||
readonly onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void;
|
||||
|
||||
/**
|
||||
* Pasted content to display inline in input options.
|
||||
*/
|
||||
readonly pastedContents?: Record<number, PastedContent>;
|
||||
|
||||
/**
|
||||
* Callback to remove a pasted image by its ID.
|
||||
*/
|
||||
readonly onRemoveImage?: (id: number) => void;
|
||||
};
|
||||
export function Select(t0) {
|
||||
const $ = _c(72);
|
||||
const {
|
||||
isDisabled: t1,
|
||||
hideIndexes: t2,
|
||||
visibleOptionCount: t3,
|
||||
highlightText,
|
||||
options,
|
||||
defaultValue,
|
||||
onCancel,
|
||||
onChange,
|
||||
onFocus,
|
||||
defaultFocusValue,
|
||||
layout: t4,
|
||||
disableSelection: t5,
|
||||
inlineDescriptions: t6,
|
||||
onUpFromFirstItem,
|
||||
onDownFromLastItem,
|
||||
onInputModeToggle,
|
||||
onOpenEditor,
|
||||
onImagePaste,
|
||||
pastedContents,
|
||||
onRemoveImage
|
||||
} = t0;
|
||||
const isDisabled = t1 === undefined ? false : t1;
|
||||
const hideIndexes = t2 === undefined ? false : t2;
|
||||
const visibleOptionCount = t3 === undefined ? 5 : t3;
|
||||
const layout = t4 === undefined ? "compact" : t4;
|
||||
const disableSelection = t5 === undefined ? false : t5;
|
||||
const inlineDescriptions = t6 === undefined ? false : t6;
|
||||
const [imagesSelected, setImagesSelected] = useState(false);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
let t7;
|
||||
if ($[0] !== options) {
|
||||
t7 = () => {
|
||||
const initialMap = new Map();
|
||||
options.forEach(option => {
|
||||
if (option.type === "input" && option.initialValue) {
|
||||
initialMap.set(option.value, option.initialValue);
|
||||
}
|
||||
});
|
||||
return initialMap;
|
||||
};
|
||||
$[0] = options;
|
||||
$[1] = t7;
|
||||
} else {
|
||||
t7 = $[1];
|
||||
}
|
||||
const [inputValues, setInputValues] = useState(t7);
|
||||
let t8;
|
||||
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t8 = new Map();
|
||||
$[2] = t8;
|
||||
} else {
|
||||
t8 = $[2];
|
||||
}
|
||||
const lastInitialValues = useRef(t8);
|
||||
let t10;
|
||||
let t9;
|
||||
if ($[3] !== inputValues || $[4] !== options) {
|
||||
t9 = () => {
|
||||
for (const option_0 of options) {
|
||||
if (option_0.type === "input" && option_0.initialValue !== undefined) {
|
||||
const lastInitial = lastInitialValues.current.get(option_0.value) ?? "";
|
||||
const currentValue = inputValues.get(option_0.value) ?? "";
|
||||
const newInitial = option_0.initialValue;
|
||||
if (newInitial !== lastInitial && currentValue === lastInitial) {
|
||||
setInputValues(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(option_0.value, newInitial);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
lastInitialValues.current.set(option_0.value, newInitial);
|
||||
}
|
||||
}
|
||||
};
|
||||
t10 = [options, inputValues];
|
||||
$[3] = inputValues;
|
||||
$[4] = options;
|
||||
$[5] = t10;
|
||||
$[6] = t9;
|
||||
} else {
|
||||
t10 = $[5];
|
||||
t9 = $[6];
|
||||
}
|
||||
useEffect(t9, t10);
|
||||
let t11;
|
||||
if ($[7] !== defaultFocusValue || $[8] !== defaultValue || $[9] !== onCancel || $[10] !== onChange || $[11] !== onFocus || $[12] !== options || $[13] !== visibleOptionCount) {
|
||||
t11 = {
|
||||
visibleOptionCount,
|
||||
options,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onCancel,
|
||||
onFocus,
|
||||
focusValue: defaultFocusValue
|
||||
};
|
||||
$[7] = defaultFocusValue;
|
||||
$[8] = defaultValue;
|
||||
$[9] = onCancel;
|
||||
$[10] = onChange;
|
||||
$[11] = onFocus;
|
||||
$[12] = options;
|
||||
$[13] = visibleOptionCount;
|
||||
$[14] = t11;
|
||||
} else {
|
||||
t11 = $[14];
|
||||
}
|
||||
const state = useSelectState(t11);
|
||||
const t12 = disableSelection || (hideIndexes ? "numeric" : false);
|
||||
let t13;
|
||||
if ($[15] !== pastedContents) {
|
||||
t13 = () => {
|
||||
if (pastedContents && Object.values(pastedContents).some(_temp)) {
|
||||
const imageCount = count(Object.values(pastedContents), _temp2);
|
||||
setImagesSelected(true);
|
||||
setSelectedImageIndex(imageCount - 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
$[15] = pastedContents;
|
||||
$[16] = t13;
|
||||
} else {
|
||||
t13 = $[16];
|
||||
}
|
||||
let t14;
|
||||
if ($[17] !== imagesSelected || $[18] !== inputValues || $[19] !== isDisabled || $[20] !== onDownFromLastItem || $[21] !== onInputModeToggle || $[22] !== onUpFromFirstItem || $[23] !== options || $[24] !== state || $[25] !== t12 || $[26] !== t13) {
|
||||
t14 = {
|
||||
isDisabled,
|
||||
disableSelection: t12,
|
||||
state,
|
||||
options,
|
||||
isMultiSelect: false,
|
||||
onUpFromFirstItem,
|
||||
onDownFromLastItem,
|
||||
onInputModeToggle,
|
||||
inputValues,
|
||||
imagesSelected,
|
||||
onEnterImageSelection: t13
|
||||
};
|
||||
$[17] = imagesSelected;
|
||||
$[18] = inputValues;
|
||||
$[19] = isDisabled;
|
||||
$[20] = onDownFromLastItem;
|
||||
$[21] = onInputModeToggle;
|
||||
$[22] = onUpFromFirstItem;
|
||||
$[23] = options;
|
||||
$[24] = state;
|
||||
$[25] = t12;
|
||||
$[26] = t13;
|
||||
$[27] = t14;
|
||||
} else {
|
||||
t14 = $[27];
|
||||
}
|
||||
useSelectInput(t14);
|
||||
let T0;
|
||||
let t15;
|
||||
let t16;
|
||||
let t17;
|
||||
if ($[28] !== hideIndexes || $[29] !== highlightText || $[30] !== imagesSelected || $[31] !== inlineDescriptions || $[32] !== inputValues || $[33] !== isDisabled || $[34] !== layout || $[35] !== onCancel || $[36] !== onChange || $[37] !== onImagePaste || $[38] !== onOpenEditor || $[39] !== onRemoveImage || $[40] !== options.length || $[41] !== pastedContents || $[42] !== selectedImageIndex || $[43] !== state.focusedValue || $[44] !== state.options || $[45] !== state.value || $[46] !== state.visibleFromIndex || $[47] !== state.visibleOptions || $[48] !== state.visibleToIndex) {
|
||||
t17 = Symbol.for("react.early_return_sentinel");
|
||||
bb0: {
|
||||
const styles = {
|
||||
container: _temp3,
|
||||
highlightedText: _temp4
|
||||
};
|
||||
if (layout === "expanded") {
|
||||
let t18;
|
||||
if ($[53] !== state.options.length) {
|
||||
t18 = state.options.length.toString();
|
||||
$[53] = state.options.length;
|
||||
$[54] = t18;
|
||||
} else {
|
||||
t18 = $[54];
|
||||
}
|
||||
const maxIndexWidth = t18.length;
|
||||
t17 = <Box {...styles.container()}>{state.visibleOptions.map((option_1, index) => {
|
||||
const isFirstVisibleOption = option_1.index === state.visibleFromIndex;
|
||||
const isLastVisibleOption = option_1.index === state.visibleToIndex - 1;
|
||||
const areMoreOptionsBelow = state.visibleToIndex < options.length;
|
||||
const areMoreOptionsAbove = state.visibleFromIndex > 0;
|
||||
const i = state.visibleFromIndex + index + 1;
|
||||
const isFocused = !isDisabled && state.focusedValue === option_1.value;
|
||||
const isSelected = state.value === option_1.value;
|
||||
if (option_1.type === "input") {
|
||||
const inputValue = inputValues.has(option_1.value) ? inputValues.get(option_1.value) : option_1.initialValue || "";
|
||||
return <SelectInputOption key={String(option_1.value)} option={option_1} isFocused={isFocused} isSelected={isSelected} shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption} shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption} maxIndexWidth={maxIndexWidth} index={i} inputValue={inputValue} onInputChange={value => {
|
||||
setInputValues(prev_0 => {
|
||||
const next_0 = new Map(prev_0);
|
||||
next_0.set(option_1.value, value);
|
||||
return next_0;
|
||||
});
|
||||
}} onSubmit={value_0 => {
|
||||
const hasImageAttachments = pastedContents && Object.values(pastedContents).some(_temp5);
|
||||
if (value_0.trim() || hasImageAttachments || option_1.allowEmptySubmitToCancel) {
|
||||
onChange?.(option_1.value);
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
}} onExit={onCancel} layout="expanded" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_1.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />;
|
||||
}
|
||||
let label = option_1.label;
|
||||
if (typeof option_1.label === "string" && highlightText && option_1.label.includes(highlightText)) {
|
||||
const labelText = option_1.label;
|
||||
const index_0 = labelText.indexOf(highlightText);
|
||||
label = <>{labelText.slice(0, index_0)}<Text {...styles.highlightedText()}>{highlightText}</Text>{labelText.slice(index_0 + highlightText.length)}</>;
|
||||
}
|
||||
const isOptionDisabled = option_1.disabled === true;
|
||||
const optionColor = isOptionDisabled ? undefined : isSelected ? "success" : isFocused ? "suggestion" : undefined;
|
||||
return <Box key={String(option_1.value)} flexDirection="column" flexShrink={0}><SelectOption isFocused={isFocused} isSelected={isSelected} shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption} shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}><Text dimColor={isOptionDisabled} color={optionColor}>{label}</Text></SelectOption>{option_1.description && <Box paddingLeft={2}><Text dimColor={isOptionDisabled || option_1.dimDescription !== false} color={optionColor}><Ansi>{option_1.description}</Ansi></Text></Box>}<Text> </Text></Box>;
|
||||
})}</Box>;
|
||||
break bb0;
|
||||
}
|
||||
if (layout === "compact-vertical") {
|
||||
let t18;
|
||||
if ($[55] !== hideIndexes || $[56] !== state.options) {
|
||||
t18 = hideIndexes ? 0 : state.options.length.toString().length;
|
||||
$[55] = hideIndexes;
|
||||
$[56] = state.options;
|
||||
$[57] = t18;
|
||||
} else {
|
||||
t18 = $[57];
|
||||
}
|
||||
const maxIndexWidth_0 = t18;
|
||||
t17 = <Box {...styles.container()}>{state.visibleOptions.map((option_2, index_1) => {
|
||||
const isFirstVisibleOption_0 = option_2.index === state.visibleFromIndex;
|
||||
const isLastVisibleOption_0 = option_2.index === state.visibleToIndex - 1;
|
||||
const areMoreOptionsBelow_0 = state.visibleToIndex < options.length;
|
||||
const areMoreOptionsAbove_0 = state.visibleFromIndex > 0;
|
||||
const i_0 = state.visibleFromIndex + index_1 + 1;
|
||||
const isFocused_0 = !isDisabled && state.focusedValue === option_2.value;
|
||||
const isSelected_0 = state.value === option_2.value;
|
||||
if (option_2.type === "input") {
|
||||
const inputValue_0 = inputValues.has(option_2.value) ? inputValues.get(option_2.value) : option_2.initialValue || "";
|
||||
return <SelectInputOption key={String(option_2.value)} option={option_2} isFocused={isFocused_0} isSelected={isSelected_0} shouldShowDownArrow={areMoreOptionsBelow_0 && isLastVisibleOption_0} shouldShowUpArrow={areMoreOptionsAbove_0 && isFirstVisibleOption_0} maxIndexWidth={maxIndexWidth_0} index={i_0} inputValue={inputValue_0} onInputChange={value_1 => {
|
||||
setInputValues(prev_1 => {
|
||||
const next_1 = new Map(prev_1);
|
||||
next_1.set(option_2.value, value_1);
|
||||
return next_1;
|
||||
});
|
||||
}} onSubmit={value_2 => {
|
||||
const hasImageAttachments_0 = pastedContents && Object.values(pastedContents).some(_temp6);
|
||||
if (value_2.trim() || hasImageAttachments_0 || option_2.allowEmptySubmitToCancel) {
|
||||
onChange?.(option_2.value);
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
}} onExit={onCancel} layout="compact" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_2.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />;
|
||||
}
|
||||
let label_0 = option_2.label;
|
||||
if (typeof option_2.label === "string" && highlightText && option_2.label.includes(highlightText)) {
|
||||
const labelText_0 = option_2.label;
|
||||
const index_2 = labelText_0.indexOf(highlightText);
|
||||
label_0 = <>{labelText_0.slice(0, index_2)}<Text {...styles.highlightedText()}>{highlightText}</Text>{labelText_0.slice(index_2 + highlightText.length)}</>;
|
||||
}
|
||||
const isOptionDisabled_0 = option_2.disabled === true;
|
||||
return <Box key={String(option_2.value)} flexDirection="column" flexShrink={0}><SelectOption isFocused={isFocused_0} isSelected={isSelected_0} shouldShowDownArrow={areMoreOptionsBelow_0 && isLastVisibleOption_0} shouldShowUpArrow={areMoreOptionsAbove_0 && isFirstVisibleOption_0}><>{!hideIndexes && <Text dimColor={true}>{`${i_0}.`.padEnd(maxIndexWidth_0 + 1)}</Text>}<Text dimColor={isOptionDisabled_0} color={isOptionDisabled_0 ? undefined : isSelected_0 ? "success" : isFocused_0 ? "suggestion" : undefined}>{label_0}</Text></></SelectOption>{option_2.description && <Box paddingLeft={hideIndexes ? 4 : maxIndexWidth_0 + 4}><Text dimColor={isOptionDisabled_0 || option_2.dimDescription !== false} color={isOptionDisabled_0 ? undefined : isSelected_0 ? "success" : isFocused_0 ? "suggestion" : undefined}><Ansi>{option_2.description}</Ansi></Text></Box>}</Box>;
|
||||
})}</Box>;
|
||||
break bb0;
|
||||
}
|
||||
let t18;
|
||||
if ($[58] !== hideIndexes || $[59] !== state.options) {
|
||||
t18 = hideIndexes ? 0 : state.options.length.toString().length;
|
||||
$[58] = hideIndexes;
|
||||
$[59] = state.options;
|
||||
$[60] = t18;
|
||||
} else {
|
||||
t18 = $[60];
|
||||
}
|
||||
const maxIndexWidth_1 = t18;
|
||||
const hasInputOptions = state.visibleOptions.some(_temp7);
|
||||
const hasDescriptions = !inlineDescriptions && !hasInputOptions && state.visibleOptions.some(_temp8);
|
||||
const optionData = state.visibleOptions.map((option_3, index_3) => {
|
||||
const isFirstVisibleOption_1 = option_3.index === state.visibleFromIndex;
|
||||
const isLastVisibleOption_1 = option_3.index === state.visibleToIndex - 1;
|
||||
const areMoreOptionsBelow_1 = state.visibleToIndex < options.length;
|
||||
const areMoreOptionsAbove_1 = state.visibleFromIndex > 0;
|
||||
const i_1 = state.visibleFromIndex + index_3 + 1;
|
||||
const isFocused_1 = !isDisabled && state.focusedValue === option_3.value;
|
||||
const isSelected_1 = state.value === option_3.value;
|
||||
const isOptionDisabled_1 = option_3.disabled === true;
|
||||
let label_1 = option_3.label;
|
||||
if (typeof option_3.label === "string" && highlightText && option_3.label.includes(highlightText)) {
|
||||
const labelText_1 = option_3.label;
|
||||
const idx = labelText_1.indexOf(highlightText);
|
||||
label_1 = <>{labelText_1.slice(0, idx)}<Text {...styles.highlightedText()}>{highlightText}</Text>{labelText_1.slice(idx + highlightText.length)}</>;
|
||||
}
|
||||
return {
|
||||
option: option_3,
|
||||
index: i_1,
|
||||
label: label_1,
|
||||
isFocused: isFocused_1,
|
||||
isSelected: isSelected_1,
|
||||
isOptionDisabled: isOptionDisabled_1,
|
||||
shouldShowDownArrow: areMoreOptionsBelow_1 && isLastVisibleOption_1,
|
||||
shouldShowUpArrow: areMoreOptionsAbove_1 && isFirstVisibleOption_1
|
||||
};
|
||||
});
|
||||
if (hasDescriptions) {
|
||||
let t19;
|
||||
if ($[61] !== hideIndexes || $[62] !== maxIndexWidth_1) {
|
||||
t19 = data => {
|
||||
if (data.option.type === "input") {
|
||||
return 0;
|
||||
}
|
||||
const labelText_2 = getTextContent(data.option.label);
|
||||
const indexWidth = hideIndexes ? 0 : maxIndexWidth_1 + 2;
|
||||
const checkmarkWidth = data.isSelected ? 2 : 0;
|
||||
return 2 + indexWidth + stringWidth(labelText_2) + checkmarkWidth;
|
||||
};
|
||||
$[61] = hideIndexes;
|
||||
$[62] = maxIndexWidth_1;
|
||||
$[63] = t19;
|
||||
} else {
|
||||
t19 = $[63];
|
||||
}
|
||||
const maxLabelWidth = Math.max(...optionData.map(t19));
|
||||
let t20;
|
||||
if ($[64] !== hideIndexes || $[65] !== maxIndexWidth_1 || $[66] !== maxLabelWidth) {
|
||||
t20 = data_0 => {
|
||||
if (data_0.option.type === "input") {
|
||||
return null;
|
||||
}
|
||||
const labelText_3 = getTextContent(data_0.option.label);
|
||||
const indexWidth_0 = hideIndexes ? 0 : maxIndexWidth_1 + 2;
|
||||
const checkmarkWidth_0 = data_0.isSelected ? 2 : 0;
|
||||
const currentLabelWidth = 2 + indexWidth_0 + stringWidth(labelText_3) + checkmarkWidth_0;
|
||||
const padding = maxLabelWidth - currentLabelWidth;
|
||||
return <TwoColumnRow key={String(data_0.option.value)} isFocused={data_0.isFocused}><Box flexDirection="row" flexShrink={0}>{data_0.isFocused ? <Text color="suggestion">{figures.pointer}</Text> : data_0.shouldShowDownArrow ? <Text dimColor={true}>{figures.arrowDown}</Text> : data_0.shouldShowUpArrow ? <Text dimColor={true}>{figures.arrowUp}</Text> : <Text> </Text>}<Text> </Text><Text dimColor={data_0.isOptionDisabled} color={data_0.isOptionDisabled ? undefined : data_0.isSelected ? "success" : data_0.isFocused ? "suggestion" : undefined}>{!hideIndexes && <Text dimColor={true}>{`${data_0.index}.`.padEnd(maxIndexWidth_1 + 2)}</Text>}{data_0.label}</Text>{data_0.isSelected && <Text color="success"> {figures.tick}</Text>}{padding > 0 && <Text>{" ".repeat(padding)}</Text>}</Box><Box flexGrow={1} marginLeft={2}><Text wrap="wrap" dimColor={data_0.isOptionDisabled || data_0.option.dimDescription !== false} color={data_0.isOptionDisabled ? undefined : data_0.isSelected ? "success" : data_0.isFocused ? "suggestion" : undefined}><Ansi>{data_0.option.description || " "}</Ansi></Text></Box></TwoColumnRow>;
|
||||
};
|
||||
$[64] = hideIndexes;
|
||||
$[65] = maxIndexWidth_1;
|
||||
$[66] = maxLabelWidth;
|
||||
$[67] = t20;
|
||||
} else {
|
||||
t20 = $[67];
|
||||
}
|
||||
t17 = <Box {...styles.container()}>{optionData.map(t20)}</Box>;
|
||||
break bb0;
|
||||
}
|
||||
T0 = Box;
|
||||
t15 = styles.container();
|
||||
t16 = state.visibleOptions.map((option_4, index_4) => {
|
||||
if (option_4.type === "input") {
|
||||
const inputValue_1 = inputValues.has(option_4.value) ? inputValues.get(option_4.value) : option_4.initialValue || "";
|
||||
const isFirstVisibleOption_2 = option_4.index === state.visibleFromIndex;
|
||||
const isLastVisibleOption_2 = option_4.index === state.visibleToIndex - 1;
|
||||
const areMoreOptionsBelow_2 = state.visibleToIndex < options.length;
|
||||
const areMoreOptionsAbove_2 = state.visibleFromIndex > 0;
|
||||
const i_2 = state.visibleFromIndex + index_4 + 1;
|
||||
const isFocused_2 = !isDisabled && state.focusedValue === option_4.value;
|
||||
const isSelected_2 = state.value === option_4.value;
|
||||
return <SelectInputOption key={String(option_4.value)} option={option_4} isFocused={isFocused_2} isSelected={isSelected_2} shouldShowDownArrow={areMoreOptionsBelow_2 && isLastVisibleOption_2} shouldShowUpArrow={areMoreOptionsAbove_2 && isFirstVisibleOption_2} maxIndexWidth={maxIndexWidth_1} index={i_2} inputValue={inputValue_1} onInputChange={value_3 => {
|
||||
setInputValues(prev_2 => {
|
||||
const next_2 = new Map(prev_2);
|
||||
next_2.set(option_4.value, value_3);
|
||||
return next_2;
|
||||
});
|
||||
}} onSubmit={value_4 => {
|
||||
const hasImageAttachments_1 = pastedContents && Object.values(pastedContents).some(_temp9);
|
||||
if (value_4.trim() || hasImageAttachments_1 || option_4.allowEmptySubmitToCancel) {
|
||||
onChange?.(option_4.value);
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
}} onExit={onCancel} layout="compact" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option_4.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} />;
|
||||
}
|
||||
let label_2 = option_4.label;
|
||||
if (typeof option_4.label === "string" && highlightText && option_4.label.includes(highlightText)) {
|
||||
const labelText_4 = option_4.label;
|
||||
const index_5 = labelText_4.indexOf(highlightText);
|
||||
label_2 = <>{labelText_4.slice(0, index_5)}<Text {...styles.highlightedText()}>{highlightText}</Text>{labelText_4.slice(index_5 + highlightText.length)}</>;
|
||||
}
|
||||
const isFirstVisibleOption_3 = option_4.index === state.visibleFromIndex;
|
||||
const isLastVisibleOption_3 = option_4.index === state.visibleToIndex - 1;
|
||||
const areMoreOptionsBelow_3 = state.visibleToIndex < options.length;
|
||||
const areMoreOptionsAbove_3 = state.visibleFromIndex > 0;
|
||||
const i_3 = state.visibleFromIndex + index_4 + 1;
|
||||
const isFocused_3 = !isDisabled && state.focusedValue === option_4.value;
|
||||
const isSelected_3 = state.value === option_4.value;
|
||||
const isOptionDisabled_2 = option_4.disabled === true;
|
||||
return <SelectOption key={String(option_4.value)} isFocused={isFocused_3} isSelected={isSelected_3} shouldShowDownArrow={areMoreOptionsBelow_3 && isLastVisibleOption_3} shouldShowUpArrow={areMoreOptionsAbove_3 && isFirstVisibleOption_3}><Box flexDirection="row" flexShrink={0}>{!hideIndexes && <Text dimColor={true}>{`${i_3}.`.padEnd(maxIndexWidth_1 + 2)}</Text>}<Text dimColor={isOptionDisabled_2} color={isOptionDisabled_2 ? undefined : isSelected_3 ? "success" : isFocused_3 ? "suggestion" : undefined}>{label_2}{inlineDescriptions && option_4.description && <Text dimColor={isOptionDisabled_2 || option_4.dimDescription !== false}>{" "}{option_4.description}</Text>}</Text></Box>{!inlineDescriptions && option_4.description && <Box flexShrink={99} marginLeft={2}><Text wrap="wrap-trim" dimColor={isOptionDisabled_2 || option_4.dimDescription !== false} color={isOptionDisabled_2 ? undefined : isSelected_3 ? "success" : isFocused_3 ? "suggestion" : undefined}><Ansi>{option_4.description}</Ansi></Text></Box>}</SelectOption>;
|
||||
});
|
||||
}
|
||||
$[28] = hideIndexes;
|
||||
$[29] = highlightText;
|
||||
$[30] = imagesSelected;
|
||||
$[31] = inlineDescriptions;
|
||||
$[32] = inputValues;
|
||||
$[33] = isDisabled;
|
||||
$[34] = layout;
|
||||
$[35] = onCancel;
|
||||
$[36] = onChange;
|
||||
$[37] = onImagePaste;
|
||||
$[38] = onOpenEditor;
|
||||
$[39] = onRemoveImage;
|
||||
$[40] = options.length;
|
||||
$[41] = pastedContents;
|
||||
$[42] = selectedImageIndex;
|
||||
$[43] = state.focusedValue;
|
||||
$[44] = state.options;
|
||||
$[45] = state.value;
|
||||
$[46] = state.visibleFromIndex;
|
||||
$[47] = state.visibleOptions;
|
||||
$[48] = state.visibleToIndex;
|
||||
$[49] = T0;
|
||||
$[50] = t15;
|
||||
$[51] = t16;
|
||||
$[52] = t17;
|
||||
} else {
|
||||
T0 = $[49];
|
||||
t15 = $[50];
|
||||
t16 = $[51];
|
||||
t17 = $[52];
|
||||
}
|
||||
if (t17 !== Symbol.for("react.early_return_sentinel")) {
|
||||
return t17;
|
||||
}
|
||||
let t18;
|
||||
if ($[68] !== T0 || $[69] !== t15 || $[70] !== t16) {
|
||||
t18 = <T0 {...t15}>{t16}</T0>;
|
||||
$[68] = T0;
|
||||
$[69] = t15;
|
||||
$[70] = t16;
|
||||
$[71] = t18;
|
||||
} else {
|
||||
t18 = $[71];
|
||||
}
|
||||
return t18;
|
||||
}
|
||||
|
||||
// Row container for the two-column (label + description) layout. Unlike
|
||||
// the other Select layouts, this one doesn't render through SelectOption →
|
||||
// ListItem, so it declares the native cursor directly. Parks the cursor
|
||||
// on the pointer indicator so screen readers / magnifiers track focus.
|
||||
function _temp9(c_3) {
|
||||
return c_3.type === "image";
|
||||
}
|
||||
function _temp8(opt_0) {
|
||||
return opt_0.description;
|
||||
}
|
||||
function _temp7(opt) {
|
||||
return opt.type === "input";
|
||||
}
|
||||
function _temp6(c_2) {
|
||||
return c_2.type === "image";
|
||||
}
|
||||
function _temp5(c_1) {
|
||||
return c_1.type === "image";
|
||||
}
|
||||
function _temp4() {
|
||||
return {
|
||||
bold: true
|
||||
};
|
||||
}
|
||||
function _temp3() {
|
||||
return {
|
||||
flexDirection: "column" as const
|
||||
};
|
||||
}
|
||||
function _temp2(c) {
|
||||
return c.type === "image";
|
||||
}
|
||||
function _temp(c_0) {
|
||||
return c_0.type === "image";
|
||||
}
|
||||
function TwoColumnRow(t0) {
|
||||
const $ = _c(5);
|
||||
const {
|
||||
isFocused,
|
||||
children
|
||||
} = t0;
|
||||
let t1;
|
||||
if ($[0] !== isFocused) {
|
||||
t1 = {
|
||||
line: 0,
|
||||
column: 0,
|
||||
active: isFocused
|
||||
};
|
||||
$[0] = isFocused;
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
const cursorRef = useDeclaredCursor(t1);
|
||||
let t2;
|
||||
if ($[2] !== children || $[3] !== cursorRef) {
|
||||
t2 = <Box ref={cursorRef} flexDirection="row">{children}</Box>;
|
||||
$[2] = children;
|
||||
$[3] = cursorRef;
|
||||
$[4] = t2;
|
||||
} else {
|
||||
t2 = $[4];
|
||||
}
|
||||
return t2;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmaWd1cmVzIiwiUmVhY3QiLCJSZWFjdE5vZGUiLCJ1c2VFZmZlY3QiLCJ1c2VSZWYiLCJ1c2VTdGF0ZSIsInVzZURlY2xhcmVkQ3Vyc29yIiwic3RyaW5nV2lkdGgiLCJBbnNpIiwiQm94IiwiVGV4dCIsImNvdW50IiwiUGFzdGVkQ29udGVudCIsIkltYWdlRGltZW5zaW9ucyIsIlNlbGVjdElucHV0T3B0aW9uIiwiU2VsZWN0T3B0aW9uIiwidXNlU2VsZWN0SW5wdXQiLCJ1c2VTZWxlY3RTdGF0ZSIsImdldFRleHRDb250ZW50Iiwibm9kZSIsIlN0cmluZyIsIkFycmF5IiwiaXNBcnJheSIsIm1hcCIsImpvaW4iLCJpc1ZhbGlkRWxlbWVudCIsImNoaWxkcmVuIiwicHJvcHMiLCJCYXNlT3B0aW9uIiwiZGVzY3JpcHRpb24iLCJkaW1EZXNjcmlwdGlvbiIsImxhYmVsIiwidmFsdWUiLCJUIiwiZGlzYWJsZWQiLCJPcHRpb25XaXRoRGVzY3JpcHRpb24iLCJ0eXBlIiwib25DaGFuZ2UiLCJwbGFjZWhvbGRlciIsImluaXRpYWxWYWx1ZSIsImFsbG93RW1wdHlTdWJtaXRUb0NhbmNlbCIsInNob3dMYWJlbFdpdGhWYWx1ZSIsImxhYmVsVmFsdWVTZXBhcmF0b3IiLCJyZXNldEN1cnNvck9uVXBkYXRlIiwiU2VsZWN0UHJvcHMiLCJpc0Rpc2FibGVkIiwiZGlzYWJsZVNlbGVjdGlvbiIsImhpZGVJbmRleGVzIiwidmlzaWJsZU9wdGlvbkNvdW50IiwiaGlnaGxpZ2h0VGV4dCIsIm9wdGlvbnMiLCJkZWZhdWx0VmFsdWUiLCJvbkNhbmNlbCIsIm9uRm9jdXMiLCJkZWZhdWx0Rm9jdXNWYWx1ZSIsImxheW91dCIsImlubGluZURlc2NyaXB0aW9ucyIsIm9uVXBGcm9tRmlyc3RJdGVtIiwib25Eb3duRnJvbUxhc3RJdGVtIiwib25JbnB1dE1vZGVUb2dnbGUiLCJvbk9wZW5FZGl0b3IiLCJjdXJyZW50VmFsdWUiLCJzZXRWYWx1ZSIsIm9uSW1hZ2VQYXN0ZSIsImJhc2U2NEltYWdlIiwibWVkaWFUeXBlIiwiZmlsZW5hbWUiLCJkaW1lbnNpb25zIiwic291cmNlUGF0aCIsInBhc3RlZENvbnRlbnRzIiwiUmVjb3JkIiwib25SZW1vdmVJbWFnZSIsImlkIiwiU2VsZWN0IiwidDAiLCIkIiwiX2MiLCJ0MSIsInQyIiwidDMiLCJ0NCIsInQ1IiwidDYiLCJ1bmRlZmluZWQiLCJpbWFnZXNTZWxlY3RlZCIsInNldEltYWdlc1NlbGVjdGVkIiwic2VsZWN0ZWRJbWFnZUluZGV4Iiwic2V0U2VsZWN0ZWRJbWFnZUluZGV4IiwidDciLCJpbml0aWFsTWFwIiwiTWFwIiwiZm9yRWFjaCIsIm9wdGlvbiIsInNldCIsImlucHV0VmFsdWVzIiwic2V0SW5wdXRWYWx1ZXMiLCJ0OCIsIlN5bWJvbCIsImZvciIsImxhc3RJbml0aWFsVmFsdWVzIiwidDEwIiwidDkiLCJvcHRpb25fMCIsImxhc3RJbml0aWFsIiwiY3VycmVudCIsImdldCIsIm5ld0luaXRpYWwiLCJwcmV2IiwibmV4dCIsInQxMSIsImZvY3VzVmFsdWUiLCJzdGF0ZSIsInQxMiIsInQxMyIsIk9iamVjdCIsInZhbHVlcyIsInNvbWUiLCJfdGVtcCIsImltYWdlQ291bnQiLCJfdGVtcDIiLCJ0MTQiLCJpc011bHRpU2VsZWN0Iiwib25FbnRlckltYWdlU2VsZWN0aW9uIiwiVDAiLCJ0MTUiLCJ0MTYiLCJ0MTciLCJsZW5ndGgiLCJmb2N1c2VkVmFsdWUiLCJ2aXNpYmxlRnJvbUluZGV4IiwidmlzaWJsZU9wdGlvbnMiLCJ2aXNpYmxlVG9JbmRleCIsImJiMCIsInN0eWxlcyIsImNvbnRhaW5lciIsIl90ZW1wMyIsImhpZ2hsaWdodGVkVGV4dCIsIl90ZW1wNCIsInQxOCIsInRvU3RyaW5nIiwibWF4SW5kZXhXaWR0aCIsIm9wdGlvbl8xIiwiaW5kZXgiLCJpc0ZpcnN0VmlzaWJsZU9wdGlvbiIsImlzTGFzdFZpc2libGVPcHRpb24iLCJhcmVNb3JlT3B0aW9uc0JlbG93IiwiYXJlTW9yZU9wdGlvbnNBYm92ZSIsImkiLCJpc0ZvY3VzZWQiLCJpc1NlbGVjdGVkIiwiaW5wdXRWYWx1ZSIsImhhcyIsInByZXZfMCIsIm5leHRfMCIsInZhbHVlXzAiLCJoYXNJbWFnZUF0dGFjaG1lbnRzIiwiX3RlbXA1IiwidHJpbSIsImluY2x1ZGVzIiwibGFiZWxUZXh0IiwiaW5kZXhfMCIsImluZGV4T2YiLCJzbGljZSIsImlzT3B0aW9uRGlzYWJsZWQiLCJvcHRpb25Db2xvciIsIm1heEluZGV4V2lkdGhfMCIsIm9wdGlvbl8yIiwiaW5kZXhfMSIsImlzRmlyc3RWaXNpYmxlT3B0aW9uXzAiLCJpc0xhc3RWaXNpYmxlT3B0aW9uXzAiLCJhcmVNb3JlT3B0aW9uc0JlbG93XzAiLCJhcmVNb3JlT3B0aW9uc0Fib3ZlXzAiLCJpXzAiLCJpc0ZvY3VzZWRfMCIsImlzU2VsZWN0ZWRfMCIsImlucHV0VmFsdWVfMCIsInZhbHVlXzEiLCJwcmV2XzEiLCJuZXh0XzEiLCJ2YWx1ZV8yIiwiaGFzSW1hZ2VBdHRhY2htZW50c18wIiwiX3RlbXA2IiwibGFiZWxfMCIsImxhYmVsVGV4dF8wIiwiaW5kZXhfMiIsImlzT3B0aW9uRGlzYWJsZWRfMCIsInBhZEVuZCIsIm1heEluZGV4V2lkdGhfMSIsImhhc0lucHV0T3B0aW9ucyIsIl90ZW1wNyIsImhhc0Rlc2NyaXB0aW9ucyIsIl90ZW1wOCIsIm9wdGlvbkRhdGEiLCJvcHRpb25fMyIsImluZGV4XzMiLCJpc0ZpcnN0VmlzaWJsZU9wdGlvbl8xIiwiaXNMYXN0VmlzaWJsZU9wdGlvbl8xIiwiYXJlTW9yZU9wdGlvbnNCZWxvd18xIiwiYXJlTW9yZU9wdGlvbnNBYm92ZV8xIiwiaV8xIiwiaXNGb2N1c2VkXzEiLCJpc1NlbGVjdGVkXzEiLCJpc09wdGlvbkRpc2FibGVkXzEiLCJsYWJlbF8xIiwibGFiZWxUZXh0XzEiLCJpZHgiLCJzaG91bGRTaG93RG93bkFycm93Iiwic2hvdWxkU2hvd1VwQXJyb3ciLCJ0MTkiLCJkYXRhIiwibGFiZWxUZXh0XzIiLCJpbmRleFdpZHRoIiwiY2hlY2ttYXJrV2lkdGgiLCJtYXhMYWJlbFdpZHRoIiwiTWF0aCIsIm1heCIsInQyMCIsImRhdGFfMCIsImxhYmVsVGV4dF8zIiwiaW5kZXhXaWR0aF8wIiwiY2hlY2ttYXJrV2lkdGhfMCIsImN1cnJlbnRMYWJlbFdpZHRoIiwicGFkZGluZyIsInBvaW50ZXIiLCJhcnJvd0Rvd24iLCJhcnJvd1VwIiwidGljayIsInJlcGVhdCIsIm9wdGlvbl80IiwiaW5kZXhfNCIsImlucHV0VmFsdWVfMSIsImlzRmlyc3RWaXNpYmxlT3B0aW9uXzIiLCJpc0xhc3RWaXNpYmxlT3B0aW9uXzIiLCJhcmVNb3JlT3B0aW9uc0JlbG93XzIiLCJhcmVNb3JlT3B0aW9uc0Fib3ZlXzIiLCJpXzIiLCJpc0ZvY3VzZWRfMiIsImlzU2VsZWN0ZWRfMiIsInZhbHVlXzMiLCJwcmV2XzIiLCJuZXh0XzIiLCJ2YWx1ZV80IiwiaGFzSW1hZ2VBdHRhY2htZW50c18xIiwiX3RlbXA5IiwibGFiZWxfMiIsImxhYmVsVGV4dF80IiwiaW5kZXhfNSIsImlzRmlyc3RWaXNpYmxlT3B0aW9uXzMiLCJpc0xhc3RWaXNpYmxlT3B0aW9uXzMiLCJhcmVNb3JlT3B0aW9uc0JlbG93XzMiLCJhcmVNb3JlT3B0aW9uc0Fib3ZlXzMiLCJpXzMiLCJpc0ZvY3VzZWRfMyIsImlzU2VsZWN0ZWRfMyIsImlzT3B0aW9uRGlzYWJsZWRfMiIsImNfMyIsImMiLCJvcHRfMCIsIm9wdCIsImNfMiIsImNfMSIsImJvbGQiLCJmbGV4RGlyZWN0aW9uIiwiY29uc3QiLCJjXzAiLCJUd29Db2x1bW5Sb3ciLCJsaW5lIiwiY29sdW1uIiwiYWN0aXZlIiwiY3Vyc29yUmVmIl0sInNvdXJjZXMiOlsic2VsZWN0LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgZmlndXJlcyBmcm9tICdmaWd1cmVzJ1xuaW1wb3J0IFJlYWN0LCB7IHR5cGUgUmVhY3ROb2RlLCB1c2VFZmZlY3QsIHVzZVJlZiwgdXNlU3RhdGUgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZURlY2xhcmVkQ3Vyc29yIH0gZnJvbSAnLi4vLi4vaW5rL2hvb2tzL3VzZS1kZWNsYXJlZC1jdXJzb3IuanMnXG5pbXBvcnQgeyBzdHJpbmdXaWR0aCB9IGZyb20gJy4uLy4uL2luay9zdHJpbmdXaWR
Line truncated
|
||||
@@ -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