/ components / CustomSelect / use-multi-select-state.ts
use-multi-select-state.ts
  1  import { useCallback, useState } from 'react'
  2  import { isDeepStrictEqual } from 'util'
  3  import { useRegisterOverlay } from '../../context/overlayContext.js'
  4  import type { InputEvent } from '../../ink/events/input-event.js'
  5  // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw space/arrow multiselect input
  6  import { useInput } from '../../ink.js'
  7  import {
  8    normalizeFullWidthDigits,
  9    normalizeFullWidthSpace,
 10  } from '../../utils/stringUtils.js'
 11  import type { OptionWithDescription } from './select.js'
 12  import { useSelectNavigation } from './use-select-navigation.js'
 13  
 14  export type UseMultiSelectStateProps<T> = {
 15    /**
 16     * When disabled, user input is ignored.
 17     *
 18     * @default false
 19     */
 20    isDisabled?: boolean
 21  
 22    /**
 23     * Number of items to display.
 24     *
 25     * @default 5
 26     */
 27    visibleOptionCount?: number
 28  
 29    /**
 30     * Options.
 31     */
 32    options: OptionWithDescription<T>[]
 33  
 34    /**
 35     * Initially selected values.
 36     */
 37    defaultValue?: T[]
 38  
 39    /**
 40     * Callback when selection changes.
 41     */
 42    onChange?: (values: T[]) => void
 43  
 44    /**
 45     * Callback for canceling the select.
 46     */
 47    onCancel: () => void
 48  
 49    /**
 50     * Callback for focusing an option.
 51     */
 52    onFocus?: (value: T) => void
 53  
 54    /**
 55     * Value to focus
 56     */
 57    focusValue?: T
 58  
 59    /**
 60     * Text for the submit button. When provided, a submit button is shown and
 61     * Enter toggles selection (submit only fires when the button is focused).
 62     * When omitted, Enter submits directly and Space toggles selection.
 63     */
 64    submitButtonText?: string
 65  
 66    /**
 67     * Callback when user submits. Receives the currently selected values.
 68     */
 69    onSubmit?: (values: T[]) => void
 70  
 71    /**
 72     * Callback when user presses down from the last item (submit button).
 73     * If provided, navigation will not wrap to the first item.
 74     */
 75    onDownFromLastItem?: () => void
 76  
 77    /**
 78     * Callback when user presses up from the first item.
 79     * If provided, navigation will not wrap to the last item.
 80     */
 81    onUpFromFirstItem?: () => void
 82  
 83    /**
 84     * Focus the last option initially instead of the first.
 85     */
 86    initialFocusLast?: boolean
 87  
 88    /**
 89     * When true, numeric keys (1-9) do not toggle options by index.
 90     * Mirrors the rendering layer's hideIndexes: if index labels aren't shown,
 91     * pressing a number shouldn't silently toggle an invisible mapping.
 92     */
 93    hideIndexes?: boolean
 94  }
 95  
 96  export type MultiSelectState<T> = {
 97    /**
 98     * Value of the currently focused option.
 99     */
100    focusedValue: T | undefined
101  
102    /**
103     * Index of the first visible option.
104     */
105    visibleFromIndex: number
106  
107    /**
108     * Index of the last visible option.
109     */
110    visibleToIndex: number
111  
112    /**
113     * All options.
114     */
115    options: OptionWithDescription<T>[]
116  
117    /**
118     * Visible options.
119     */
120    visibleOptions: Array<OptionWithDescription<T> & { index: number }>
121  
122    /**
123     * Whether the focused option is an input type.
124     */
125    isInInput: boolean
126  
127    /**
128     * Currently selected values.
129     */
130    selectedValues: T[]
131  
132    /**
133     * Current input field values.
134     */
135    inputValues: Map<T, string>
136  
137    /**
138     * Whether the submit button is focused.
139     */
140    isSubmitFocused: boolean
141  
142    /**
143     * Update an input field value.
144     */
145    updateInputValue: (value: T, inputValue: string) => void
146  
147    /**
148     * Callback for canceling the select.
149     */
150    onCancel: () => void
151  }
152  
153  export function useMultiSelectState<T>({
154    isDisabled = false,
155    visibleOptionCount = 5,
156    options,
157    defaultValue = [],
158    onChange,
159    onCancel,
160    onFocus,
161    focusValue,
162    submitButtonText,
163    onSubmit,
164    onDownFromLastItem,
165    onUpFromFirstItem,
166    initialFocusLast,
167    hideIndexes = false,
168  }: UseMultiSelectStateProps<T>): MultiSelectState<T> {
169    const [selectedValues, setSelectedValues] = useState<T[]>(defaultValue)
170    const [isSubmitFocused, setIsSubmitFocused] = useState(false)
171  
172    // Reset selectedValues when options change (e.g. async-loaded data changes
173    // defaultValue after mount). Mirrors the reset pattern in use-select-navigation.ts
174    // and the deleted ui/useMultiSelectState.ts — without this, MCPServerDesktopImportDialog
175    // keeps colliding servers checked after getAllMcpConfigs() resolves.
176    const [lastOptions, setLastOptions] = useState(options)
177    if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
178      setSelectedValues(defaultValue)
179      setLastOptions(options)
180    }
181  
182    // State for input type options
183    const [inputValues, setInputValues] = useState<Map<T, string>>(() => {
184      const initialMap = new Map<T, string>()
185      options.forEach(option => {
186        if (option.type === 'input' && option.initialValue) {
187          initialMap.set(option.value, option.initialValue)
188        }
189      })
190      return initialMap
191    })
192  
193    const updateSelectedValues = useCallback(
194      (values: T[] | ((prev: T[]) => T[])) => {
195        const newValues =
196          typeof values === 'function' ? values(selectedValues) : values
197        setSelectedValues(newValues)
198        onChange?.(newValues)
199      },
200      [selectedValues, onChange],
201    )
202  
203    const navigation = useSelectNavigation<T>({
204      visibleOptionCount,
205      options,
206      initialFocusValue: initialFocusLast
207        ? options[options.length - 1]?.value
208        : undefined,
209      onFocus,
210      focusValue,
211    })
212  
213    // Automatically register as an overlay.
214    // This ensures CancelRequestHandler won't intercept Escape when the multi-select is active.
215    useRegisterOverlay('multi-select')
216  
217    const updateInputValue = useCallback(
218      (value: T, inputValue: string) => {
219        setInputValues(prev => {
220          const next = new Map(prev)
221          next.set(value, inputValue)
222          return next
223        })
224  
225        // Find the option and call its onChange
226        const option = options.find(opt => opt.value === value)
227        if (option && option.type === 'input') {
228          option.onChange(inputValue)
229        }
230  
231        // Update selected values to include/exclude based on input
232        updateSelectedValues(prev => {
233          if (inputValue) {
234            if (!prev.includes(value)) {
235              return [...prev, value]
236            }
237            return prev
238          } else {
239            return prev.filter(v => v !== value)
240          }
241        })
242      },
243      [options, updateSelectedValues],
244    )
245  
246    // Handle all keyboard input
247    useInput(
248      (input, key, event: InputEvent) => {
249        const normalizedInput = normalizeFullWidthDigits(input)
250        const focusedOption = options.find(
251          opt => opt.value === navigation.focusedValue,
252        )
253        const isInInput = focusedOption?.type === 'input'
254  
255        // When in input field, only allow navigation keys
256        if (isInInput) {
257          const isAllowedKey =
258            key.upArrow ||
259            key.downArrow ||
260            key.escape ||
261            key.tab ||
262            key.return ||
263            (key.ctrl && (input === 'n' || input === 'p' || key.return))
264          if (!isAllowedKey) return
265        }
266  
267        const lastOptionValue = options[options.length - 1]?.value
268  
269        // Handle Tab to move forward
270        if (key.tab && !key.shift) {
271          if (
272            submitButtonText &&
273            onSubmit &&
274            navigation.focusedValue === lastOptionValue &&
275            !isSubmitFocused
276          ) {
277            setIsSubmitFocused(true)
278          } else if (!isSubmitFocused) {
279            navigation.focusNextOption()
280          }
281          return
282        }
283  
284        // Handle Shift+Tab to move backward
285        if (key.tab && key.shift) {
286          if (submitButtonText && onSubmit && isSubmitFocused) {
287            setIsSubmitFocused(false)
288            navigation.focusOption(lastOptionValue)
289          } else {
290            navigation.focusPreviousOption()
291          }
292          return
293        }
294  
295        // Handle arrow down / Ctrl+N / j
296        if (
297          key.downArrow ||
298          (key.ctrl && input === 'n') ||
299          (!key.ctrl && !key.shift && input === 'j')
300        ) {
301          if (isSubmitFocused && onDownFromLastItem) {
302            onDownFromLastItem()
303          } else if (
304            submitButtonText &&
305            onSubmit &&
306            navigation.focusedValue === lastOptionValue &&
307            !isSubmitFocused
308          ) {
309            setIsSubmitFocused(true)
310          } else if (
311            !submitButtonText &&
312            onDownFromLastItem &&
313            navigation.focusedValue === lastOptionValue
314          ) {
315            // No submit button — exit from the last option
316            onDownFromLastItem()
317          } else if (!isSubmitFocused) {
318            navigation.focusNextOption()
319          }
320          return
321        }
322  
323        // Handle arrow up / Ctrl+P / k
324        if (
325          key.upArrow ||
326          (key.ctrl && input === 'p') ||
327          (!key.ctrl && !key.shift && input === 'k')
328        ) {
329          if (submitButtonText && onSubmit && isSubmitFocused) {
330            setIsSubmitFocused(false)
331            navigation.focusOption(lastOptionValue)
332          } else if (
333            onUpFromFirstItem &&
334            navigation.focusedValue === options[0]?.value
335          ) {
336            onUpFromFirstItem()
337          } else {
338            navigation.focusPreviousOption()
339          }
340          return
341        }
342  
343        // Handle page navigation
344        if (key.pageDown) {
345          navigation.focusNextPage()
346          return
347        }
348  
349        if (key.pageUp) {
350          navigation.focusPreviousPage()
351          return
352        }
353  
354        // Handle Enter or Space for selection/submit
355        if (key.return || normalizeFullWidthSpace(input) === ' ') {
356          // Ctrl+Enter from input field submits
357          if (key.ctrl && key.return && isInInput && onSubmit) {
358            onSubmit(selectedValues)
359            return
360          }
361  
362          // Enter on submit button submits
363          if (isSubmitFocused && onSubmit) {
364            onSubmit(selectedValues)
365            return
366          }
367  
368          // No submit button: Enter submits directly, Space still toggles
369          if (key.return && !submitButtonText && onSubmit) {
370            onSubmit(selectedValues)
371            return
372          }
373  
374          // Enter or Space toggles selection (including for input fields)
375          if (navigation.focusedValue !== undefined) {
376            const newValues = selectedValues.includes(navigation.focusedValue)
377              ? selectedValues.filter(v => v !== navigation.focusedValue)
378              : [...selectedValues, navigation.focusedValue]
379            updateSelectedValues(newValues)
380          }
381          return
382        }
383  
384        // Handle numeric keys (1-9) for direct selection
385        if (!hideIndexes && /^[0-9]+$/.test(normalizedInput)) {
386          const index = parseInt(normalizedInput) - 1
387          if (index >= 0 && index < options.length) {
388            const value = options[index]!.value
389            const newValues = selectedValues.includes(value)
390              ? selectedValues.filter(v => v !== value)
391              : [...selectedValues, value]
392            updateSelectedValues(newValues)
393          }
394          return
395        }
396  
397        // Handle Escape
398        if (key.escape) {
399          onCancel()
400          event.stopImmediatePropagation()
401        }
402      },
403      { isActive: !isDisabled },
404    )
405  
406    return {
407      ...navigation,
408      selectedValues,
409      inputValues,
410      isSubmitFocused,
411      updateInputValue,
412      onCancel,
413    }
414  }