use-multi-select-state.ts
components/CustomSelect/use-multi-select-state.ts
415
Lines
10952
Bytes
3
Exports
8
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with ui-flow. It contains 415 lines, 8 detected imports, and 3 detected exports.
Important relationships
- components/CustomSelect/SelectMulti.tsx
- components/CustomSelect/index.ts
- components/CustomSelect/option-map.ts
- components/CustomSelect/select-input-option.tsx
- components/CustomSelect/select-option.tsx
- components/CustomSelect/select.tsx
- components/CustomSelect/use-select-input.ts
- components/CustomSelect/use-select-navigation.ts
Detected exports
UseMultiSelectStatePropsMultiSelectStateuseMultiSelectState
Keywords
optionsinputnavigationoptionselectedvalueswhenonsubmitvaluesissubmitfocusedfocusedvalue
Detected imports
reactutil../../context/overlayContext.js../../ink/events/input-event.js../../ink.js../../utils/stringUtils.js./select.js./use-select-navigation.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
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,
}
}