/ components / CustomSelect / use-select-input.ts
use-select-input.ts
  1  import { useMemo } from 'react'
  2  import { useRegisterOverlay } from '../../context/overlayContext.js'
  3  import type { InputEvent } from '../../ink/events/input-event.js'
  4  import { useInput } from '../../ink.js'
  5  import { useKeybindings } from '../../keybindings/useKeybinding.js'
  6  import {
  7    normalizeFullWidthDigits,
  8    normalizeFullWidthSpace,
  9  } from '../../utils/stringUtils.js'
 10  import type { OptionWithDescription } from './select.js'
 11  import type { SelectState } from './use-select-state.js'
 12  
 13  export type UseSelectProps<T> = {
 14    /**
 15     * When disabled, user input is ignored.
 16     *
 17     * @default false
 18     */
 19    isDisabled?: boolean
 20  
 21    /**
 22     * When true, prevents selection on Enter or number keys, but allows
 23     * scrolling.
 24     * When 'numeric', prevents selection on number keys, but allows Enter (and
 25     * scrolling).
 26     *
 27     * @default false
 28     */
 29    readonly disableSelection?: boolean | 'numeric'
 30  
 31    /**
 32     * Select state.
 33     */
 34    state: SelectState<T>
 35  
 36    /**
 37     * Options.
 38     */
 39    options: OptionWithDescription<T>[]
 40  
 41    /**
 42     * Whether this is a multi-select component.
 43     *
 44     * @default false
 45     */
 46    isMultiSelect?: boolean
 47  
 48    /**
 49     * Callback when user presses up from the first item.
 50     * If provided, navigation will not wrap to the last item.
 51     */
 52    onUpFromFirstItem?: () => void
 53  
 54    /**
 55     * Callback when user presses down from the last item.
 56     * If provided, navigation will not wrap to the first item.
 57     */
 58    onDownFromLastItem?: () => void
 59  
 60    /**
 61     * Callback when input mode should be toggled for an option.
 62     * Called when Tab is pressed (to enter or exit input mode).
 63     */
 64    onInputModeToggle?: (value: T) => void
 65  
 66    /**
 67     * Current input values for input-type options.
 68     * Used to determine if number key should submit an empty input option.
 69     */
 70    inputValues?: Map<T, string>
 71  
 72    /**
 73     * Whether image selection mode is active on the focused input option.
 74     * When true, arrow key navigation in useInput is suppressed so that
 75     * Attachments keybindings can handle image navigation instead.
 76     */
 77    imagesSelected?: boolean
 78  
 79    /**
 80     * Callback to attempt entering image selection mode on DOWN arrow.
 81     * Returns true if image selection was entered (images exist), false otherwise.
 82     */
 83    onEnterImageSelection?: () => boolean
 84  }
 85  
 86  export const useSelectInput = <T>({
 87    isDisabled = false,
 88    disableSelection = false,
 89    state,
 90    options,
 91    isMultiSelect = false,
 92    onUpFromFirstItem,
 93    onDownFromLastItem,
 94    onInputModeToggle,
 95    inputValues,
 96    imagesSelected = false,
 97    onEnterImageSelection,
 98  }: UseSelectProps<T>) => {
 99    // Automatically register as an overlay when onCancel is provided.
100    // This ensures CancelRequestHandler won't intercept Escape when the select is active.
101    useRegisterOverlay('select', !!state.onCancel)
102  
103    // Determine if the focused option is an input type
104    const isInInput = useMemo(() => {
105      const focusedOption = options.find(opt => opt.value === state.focusedValue)
106      return focusedOption?.type === 'input'
107    }, [options, state.focusedValue])
108  
109    // Core navigation via keybindings (up/down/enter/escape)
110    // When in input mode, exclude navigation/accept keybindings so that
111    // j/k/enter pass through to the TextInput instead of being intercepted.
112    const keybindingHandlers = useMemo(() => {
113      const handlers: Record<string, () => void> = {}
114  
115      if (!isInInput) {
116        handlers['select:next'] = () => {
117          if (onDownFromLastItem) {
118            const lastOption = options[options.length - 1]
119            if (lastOption && state.focusedValue === lastOption.value) {
120              onDownFromLastItem()
121              return
122            }
123          }
124          state.focusNextOption()
125        }
126        handlers['select:previous'] = () => {
127          if (onUpFromFirstItem && state.visibleFromIndex === 0) {
128            const firstOption = options[0]
129            if (firstOption && state.focusedValue === firstOption.value) {
130              onUpFromFirstItem()
131              return
132            }
133          }
134          state.focusPreviousOption()
135        }
136        handlers['select:accept'] = () => {
137          if (disableSelection === true) return
138          if (state.focusedValue === undefined) return
139  
140          const focusedOption = options.find(
141            opt => opt.value === state.focusedValue,
142          )
143          if (focusedOption?.disabled === true) return
144  
145          state.selectFocusedOption?.()
146          state.onChange?.(state.focusedValue)
147        }
148      }
149  
150      if (state.onCancel) {
151        handlers['select:cancel'] = () => {
152          state.onCancel!()
153        }
154      }
155  
156      return handlers
157    }, [
158      options,
159      state,
160      onDownFromLastItem,
161      onUpFromFirstItem,
162      isInInput,
163      disableSelection,
164    ])
165  
166    useKeybindings(keybindingHandlers, {
167      context: 'Select',
168      isActive: !isDisabled,
169    })
170  
171    // Remaining keys that stay as useInput: number keys, pageUp/pageDown, tab, space,
172    // and arrow key navigation when in input mode
173    useInput(
174      (input, key, event: InputEvent) => {
175        const normalizedInput = normalizeFullWidthDigits(input)
176        const focusedOption = options.find(
177          opt => opt.value === state.focusedValue,
178        )
179        const currentIsInInput = focusedOption?.type === 'input'
180  
181        // Handle Tab key for input mode toggling
182        if (key.tab && onInputModeToggle && state.focusedValue !== undefined) {
183          onInputModeToggle(state.focusedValue)
184          return
185        }
186  
187        if (currentIsInInput) {
188          // When in image selection mode, suppress all input handling so
189          // Attachments keybindings can handle navigation/deletion instead
190          if (imagesSelected) return
191  
192          // DOWN arrow enters image selection mode if images exist
193          if (key.downArrow && onEnterImageSelection?.()) {
194            event.stopImmediatePropagation()
195            return
196          }
197  
198          // Arrow keys still navigate the select even while in input mode
199          if (key.downArrow || (key.ctrl && input === 'n')) {
200            if (onDownFromLastItem) {
201              const lastOption = options[options.length - 1]
202              if (lastOption && state.focusedValue === lastOption.value) {
203                onDownFromLastItem()
204                event.stopImmediatePropagation()
205                return
206              }
207            }
208            state.focusNextOption()
209            event.stopImmediatePropagation()
210            return
211          }
212          if (key.upArrow || (key.ctrl && input === 'p')) {
213            if (onUpFromFirstItem && state.visibleFromIndex === 0) {
214              const firstOption = options[0]
215              if (firstOption && state.focusedValue === firstOption.value) {
216                onUpFromFirstItem()
217                event.stopImmediatePropagation()
218                return
219              }
220            }
221            state.focusPreviousOption()
222            event.stopImmediatePropagation()
223            return
224          }
225  
226          // All other keys (including digits) pass through to TextInput.
227          // Digits should type literally into the input rather than select
228          // options — the user has focused a text field and expects typing
229          // to insert characters, not jump to a different option.
230          return
231        }
232  
233        if (key.pageDown) {
234          state.focusNextPage()
235        }
236  
237        if (key.pageUp) {
238          state.focusPreviousPage()
239        }
240  
241        if (disableSelection !== true) {
242          // Space for multi-select toggle
243          if (
244            isMultiSelect &&
245            normalizeFullWidthSpace(input) === ' ' &&
246            state.focusedValue !== undefined
247          ) {
248            const isFocusedOptionDisabled = focusedOption?.disabled === true
249            if (!isFocusedOptionDisabled) {
250              state.selectFocusedOption?.()
251              state.onChange?.(state.focusedValue)
252            }
253          }
254  
255          if (
256            disableSelection !== 'numeric' &&
257            /^[0-9]+$/.test(normalizedInput)
258          ) {
259            const index = parseInt(normalizedInput) - 1
260            if (index >= 0 && index < state.options.length) {
261              const selectedOption = state.options[index]!
262              if (selectedOption.disabled === true) {
263                return
264              }
265              if (selectedOption.type === 'input') {
266                const currentValue = inputValues?.get(selectedOption.value) ?? ''
267                if (currentValue.trim()) {
268                  // Pre-filled input: auto-submit (user can Tab to edit instead)
269                  state.onChange?.(selectedOption.value)
270                  return
271                }
272                if (selectedOption.allowEmptySubmitToCancel) {
273                  state.onChange?.(selectedOption.value)
274                  return
275                }
276                state.focusOption(selectedOption.value)
277                return
278              }
279              state.onChange?.(selectedOption.value)
280              return
281            }
282          }
283        }
284      },
285      { isActive: !isDisabled },
286    )
287  }