source dump of claude code
at main 287 lines 8.8 kB view raw
1import { useMemo } from 'react' 2import { useRegisterOverlay } from '../../context/overlayContext.js' 3import type { InputEvent } from '../../ink/events/input-event.js' 4import { useInput } from '../../ink.js' 5import { useKeybindings } from '../../keybindings/useKeybinding.js' 6import { 7 normalizeFullWidthDigits, 8 normalizeFullWidthSpace, 9} from '../../utils/stringUtils.js' 10import type { OptionWithDescription } from './select.js' 11import type { SelectState } from './use-select-state.js' 12 13export 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 86export 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}