use-select-input.ts
components/CustomSelect/use-select-input.ts
288
Lines
8770
Bytes
2
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 288 lines, 8 detected imports, and 2 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-multi-select-state.ts
- components/CustomSelect/use-select-navigation.ts
Detected exports
UseSelectPropsuseSelectInput
Keywords
inputoptionsfocusedvaluewhenselectmodeselectedoptionnavigationselectiononupfromfirstitem
Detected imports
react../../context/overlayContext.js../../ink/events/input-event.js../../ink.js../../keybindings/useKeybinding.js../../utils/stringUtils.js./select.js./use-select-state.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 { 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 },
)
}