forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuse-select-input.ts
More file actions
287 lines (257 loc) · 8.56 KB
/
Copy pathuse-select-input.ts
File metadata and controls
287 lines (257 loc) · 8.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
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 },
)
}