@tooee/choose 0.1.22 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +28 -2
  2. package/dist/Choose.d.ts +14 -17
  3. package/dist/Choose.d.ts.map +1 -1
  4. package/dist/Choose.js +42 -266
  5. package/dist/Choose.js.map +1 -1
  6. package/dist/ChooseFilter.d.ts +12 -0
  7. package/dist/ChooseFilter.d.ts.map +1 -0
  8. package/dist/ChooseFilter.js +9 -0
  9. package/dist/ChooseFilter.js.map +1 -0
  10. package/dist/ChooseHighlightedText.d.ts +9 -0
  11. package/dist/ChooseHighlightedText.d.ts.map +1 -0
  12. package/dist/ChooseHighlightedText.js +30 -0
  13. package/dist/ChooseHighlightedText.js.map +1 -0
  14. package/dist/ChooseList.d.ts +28 -0
  15. package/dist/ChooseList.d.ts.map +1 -0
  16. package/dist/ChooseList.js +51 -0
  17. package/dist/ChooseList.js.map +1 -0
  18. package/dist/ChooseOverlay.d.ts +34 -5
  19. package/dist/ChooseOverlay.d.ts.map +1 -1
  20. package/dist/ChooseOverlay.js +29 -200
  21. package/dist/ChooseOverlay.js.map +1 -1
  22. package/dist/ChoosePanel.d.ts +30 -0
  23. package/dist/ChoosePanel.d.ts.map +1 -0
  24. package/dist/ChoosePanel.js +25 -0
  25. package/dist/ChoosePanel.js.map +1 -0
  26. package/dist/index.d.ts +13 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +6 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/source.d.ts +5 -0
  31. package/dist/source.d.ts.map +1 -0
  32. package/dist/source.js +12 -0
  33. package/dist/source.js.map +1 -0
  34. package/dist/types.d.ts +2 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/use-choose.d.ts +80 -0
  37. package/dist/use-choose.d.ts.map +1 -0
  38. package/dist/use-choose.js +365 -0
  39. package/dist/use-choose.js.map +1 -0
  40. package/package.json +11 -11
  41. package/src/Choose.tsx +68 -355
  42. package/src/ChooseFilter.tsx +45 -0
  43. package/src/ChooseHighlightedText.tsx +42 -0
  44. package/src/ChooseList.tsx +137 -0
  45. package/src/ChooseOverlay.tsx +73 -307
  46. package/src/ChoosePanel.tsx +123 -0
  47. package/src/index.ts +26 -1
  48. package/src/source.ts +12 -0
  49. package/src/types.ts +6 -0
  50. package/src/use-choose.ts +479 -0
package/src/source.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { ChooseItem, ChooseSource } from "./types.js"
2
+
3
+ /** Resolve every public Choose source shape through one compatibility boundary. */
4
+ export function loadChooseSource(source: ChooseSource): ChooseItem[] | Promise<ChooseItem[]> {
5
+ if (Array.isArray(source)) return source
6
+ if (typeof source === "function") return source()
7
+ return source.load()
8
+ }
9
+
10
+ export function chooseSourceError(error: unknown): string {
11
+ return error instanceof Error ? error.message : String(error)
12
+ }
package/src/types.ts CHANGED
@@ -9,6 +9,12 @@ export interface ChooseContentProvider {
9
9
  load(): Promise<ChooseItem[]> | ChooseItem[]
10
10
  }
11
11
 
12
+ /** Any item source accepted by the shared chooser core. */
13
+ export type ChooseSource =
14
+ | ChooseItem[]
15
+ | ChooseContentProvider
16
+ | (() => ChooseItem[] | Promise<ChooseItem[]>)
17
+
12
18
  export interface ChooseResult {
13
19
  items: ChooseItem[]
14
20
  }
@@ -0,0 +1,479 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import type { InputRenderable, KeyEvent } from "@opentui/core"
3
+ import {
4
+ useActiveCommandSurface,
5
+ useActions,
6
+ useCommand,
7
+ useCommandSurfaceId,
8
+ useMode,
9
+ useProvideCommandContext,
10
+ useSetMode,
11
+ type ActionDefinition,
12
+ type Mode,
13
+ } from "@tooee/commands"
14
+ import { fuzzyFilter, type FuzzyMatch } from "./fuzzy.js"
15
+ import { chooseSourceError, loadChooseSource } from "./source.js"
16
+ import type { ChooseItem, ChooseResult, ChooseSource } from "./types.js"
17
+
18
+ declare module "@tooee/commands" {
19
+ interface CommandContext {
20
+ /** Contributed by chooser primitives: current filtering and selection state. */
21
+ choose: {
22
+ activeItem: ChooseItem | undefined
23
+ selectedItems: ChooseItem[]
24
+ filterQuery: string
25
+ }
26
+ }
27
+ }
28
+
29
+ export type ChooseCommandGroup = "navigation" | "mode" | "submit" | "cancel" | "multi-select"
30
+
31
+ export interface UseChooseOptions {
32
+ source: ChooseSource
33
+ multi?: boolean
34
+ initialFilter?: string
35
+ onSubmit: (result: ChooseResult) => void | Promise<void>
36
+ onCancel?: () => void
37
+ commands?: ActionDefinition[]
38
+ /** Prefix for built-in command ids (default `choose`). */
39
+ commandScope?: string
40
+ /** Disable a built-in command group when a host owns those keys. */
41
+ disable?: ChooseCommandGroup[]
42
+ /** Explicitly blur/disable mouse views while a legacy covering surface is open. */
43
+ suspended?: boolean
44
+ }
45
+
46
+ export interface ChooseController {
47
+ getFilter(): string
48
+ setFilter(query: string): void
49
+ clearFilter(): void
50
+ moveUp(): void
51
+ moveDown(): void
52
+ setActiveIndex(index: number): void
53
+ getActiveItem(): ChooseItem | undefined
54
+ getSelectedItems(): ChooseItem[]
55
+ toggleActive(): void
56
+ submit(): void
57
+ cancel(): void
58
+ reload(): void
59
+ readonly mode: Mode
60
+ setMode(mode: Mode): void
61
+ }
62
+
63
+ export interface ChooseState {
64
+ items: ChooseItem[]
65
+ matches: FuzzyMatch[]
66
+ filterQuery: string
67
+ activeIndex: number
68
+ activeItem: ChooseItem | undefined
69
+ selectedOriginalIndices: ReadonlySet<number>
70
+ selectedItems: ChooseItem[]
71
+ loading: boolean
72
+ error: string | null
73
+ multi: boolean
74
+ }
75
+
76
+ export interface ChooseViewModel {
77
+ filterRef: { current: InputRenderable | null }
78
+ mode: Mode
79
+ /** True while another modal surface (or an explicit host guard) owns interaction. */
80
+ suspended: boolean
81
+ filterFocused: boolean
82
+ onFilterInput: (query: string) => void
83
+ /** Input-level Tab bridge; OpenTUI consumes Tab before global dispatch. */
84
+ onFilterKeyDown: (event: KeyEvent) => void
85
+ }
86
+
87
+ export interface UseChooseResult {
88
+ controller: ChooseController
89
+ state: ChooseState
90
+ view: ChooseViewModel
91
+ }
92
+
93
+ /**
94
+ * Shared headless chooser: source lifecycle, fuzzy matches, selection,
95
+ * command context, controller, and all built-in keyboard commands.
96
+ */
97
+ export function useChoose(options: UseChooseOptions): UseChooseResult {
98
+ const { source, multi = false, initialFilter = "", commandScope = "choose" } = options
99
+ const initialItems = Array.isArray(source) ? source : []
100
+
101
+ const [items, setItems] = useState<ChooseItem[]>(initialItems)
102
+ const [filterQuery, setFilterQuery] = useState(initialFilter)
103
+ const [activeIndex, setActiveIndexState] = useState(0)
104
+ const [selectedOriginalIndices, setSelectedOriginalIndices] = useState<Set<number>>(new Set())
105
+ const [loading, setLoading] = useState(!Array.isArray(source))
106
+ const [error, setError] = useState<string | null>(null)
107
+ const [reloadRevision, setReloadRevision] = useState(0)
108
+ const filterRef = useRef<InputRenderable>(null)
109
+ const requestIdRef = useRef(0)
110
+ const initialArraySourceRef = useRef<ChooseItem[] | null>(Array.isArray(source) ? source : null)
111
+ const didHandleInitialSourceRef = useRef(false)
112
+
113
+ const matches = useMemo(() => fuzzyFilter(items, filterQuery), [items, filterQuery])
114
+ const activeItem = matches[activeIndex]?.item
115
+ const selectedItems = useMemo(() => {
116
+ if (!multi) return activeItem ? [activeItem] : []
117
+ const selected = Array.from(selectedOriginalIndices).flatMap((index) => {
118
+ const item = items[index]
119
+ return item ? [item] : []
120
+ })
121
+ return selected.length > 0 ? selected : activeItem ? [activeItem] : []
122
+ }, [multi, activeItem, selectedOriginalIndices, items])
123
+
124
+ const mode = useMode()
125
+ const setMode = useSetMode()
126
+
127
+ // Stable controller methods read live values through refs, including when
128
+ // multiple imperative operations happen before React renders again.
129
+ const optionsRef = useRef(options)
130
+ optionsRef.current = options
131
+ const itemsRef = useRef(items)
132
+ itemsRef.current = items
133
+ const matchesRef = useRef(matches)
134
+ matchesRef.current = matches
135
+ const filterQueryRef = useRef(filterQuery)
136
+ filterQueryRef.current = filterQuery
137
+ const activeIndexRef = useRef(activeIndex)
138
+ activeIndexRef.current = activeIndex
139
+ const selectedRef = useRef(selectedOriginalIndices)
140
+ selectedRef.current = selectedOriginalIndices
141
+ const multiRef = useRef(multi)
142
+ multiRef.current = multi
143
+ const modeRef = useRef(mode)
144
+ modeRef.current = mode
145
+
146
+ const replaceItems = useCallback((nextItems: ChooseItem[]) => {
147
+ itemsRef.current = nextItems
148
+ matchesRef.current = fuzzyFilter(nextItems, filterQueryRef.current)
149
+ activeIndexRef.current = 0
150
+ selectedRef.current = new Set()
151
+ setItems(nextItems)
152
+ setActiveIndexState(0)
153
+ setSelectedOriginalIndices(selectedRef.current)
154
+ }, [])
155
+
156
+ useEffect(() => {
157
+ const requestId = ++requestIdRef.current
158
+ let active = true
159
+ let result: ChooseItem[] | Promise<ChooseItem[]>
160
+
161
+ // Direct arrays seed state during the first render. Avoid a redundant
162
+ // post-mount reset that could overwrite immediate controller operations.
163
+ if (
164
+ !didHandleInitialSourceRef.current &&
165
+ Array.isArray(source) &&
166
+ source === initialArraySourceRef.current
167
+ ) {
168
+ didHandleInitialSourceRef.current = true
169
+ setLoading(false)
170
+ return
171
+ }
172
+ didHandleInitialSourceRef.current = true
173
+
174
+ setError(null)
175
+ try {
176
+ result = loadChooseSource(source)
177
+ } catch (loadError) {
178
+ if (requestId !== requestIdRef.current) return
179
+ replaceItems([])
180
+ setError(chooseSourceError(loadError))
181
+ setLoading(false)
182
+ return
183
+ }
184
+
185
+ if (result instanceof Promise) {
186
+ setLoading(true)
187
+ void result.then(
188
+ (loaded) => {
189
+ if (!active || requestId !== requestIdRef.current) return
190
+ replaceItems(loaded)
191
+ setError(null)
192
+ setLoading(false)
193
+ },
194
+ (loadError: unknown) => {
195
+ if (!active || requestId !== requestIdRef.current) return
196
+ replaceItems([])
197
+ setError(chooseSourceError(loadError))
198
+ setLoading(false)
199
+ },
200
+ )
201
+ return () => {
202
+ active = false
203
+ }
204
+ }
205
+
206
+ replaceItems(result)
207
+ setLoading(false)
208
+ }, [source, reloadRevision, replaceItems])
209
+
210
+ const setFilter = useCallback((query: string) => {
211
+ // Controlled OpenTUI inputs may echo a programmatic `value` update through
212
+ // onInput. Only a real query change resets navigation.
213
+ if (query === filterQueryRef.current) {
214
+ setFilterQuery(query)
215
+ return
216
+ }
217
+ filterQueryRef.current = query
218
+ matchesRef.current = fuzzyFilter(itemsRef.current, query)
219
+ activeIndexRef.current = 0
220
+ setFilterQuery(query)
221
+ setActiveIndexState(0)
222
+ }, [])
223
+
224
+ const setActiveIndex = useCallback((index: number) => {
225
+ const lastIndex = Math.max(0, matchesRef.current.length - 1)
226
+ const next = Math.min(lastIndex, Math.max(0, index))
227
+ activeIndexRef.current = next
228
+ setActiveIndexState(next)
229
+ }, [])
230
+
231
+ const moveUp = useCallback(() => setActiveIndex(activeIndexRef.current - 1), [setActiveIndex])
232
+ const moveDown = useCallback(() => setActiveIndex(activeIndexRef.current + 1), [setActiveIndex])
233
+
234
+ const getActiveItem = useCallback(() => matchesRef.current[activeIndexRef.current]?.item, [])
235
+
236
+ const getSelectedItems = useCallback((): ChooseItem[] => {
237
+ const active = getActiveItem()
238
+ if (!multiRef.current) return active ? [active] : []
239
+ const selected = Array.from(selectedRef.current).flatMap((index) => {
240
+ const item = itemsRef.current[index]
241
+ return item ? [item] : []
242
+ })
243
+ return selected.length > 0 ? selected : active ? [active] : []
244
+ }, [getActiveItem])
245
+
246
+ const toggleActive = useCallback(() => {
247
+ if (!multiRef.current) return
248
+ const originalIndex = matchesRef.current[activeIndexRef.current]?.originalIndex
249
+ if (originalIndex === undefined) return
250
+ const next = new Set(selectedRef.current)
251
+ if (next.has(originalIndex)) next.delete(originalIndex)
252
+ else next.add(originalIndex)
253
+ selectedRef.current = next
254
+ setSelectedOriginalIndices(next)
255
+ }, [])
256
+
257
+ const submit = useCallback(() => {
258
+ void optionsRef.current.onSubmit({ items: getSelectedItems() })
259
+ }, [getSelectedItems])
260
+
261
+ const cancel = useCallback(() => optionsRef.current.onCancel?.(), [])
262
+ const reload = useCallback(() => setReloadRevision((revision) => revision + 1), [])
263
+ const setModeExternal = useCallback((nextMode: Mode) => setMode(nextMode), [setMode])
264
+
265
+ useProvideCommandContext(() => ({
266
+ choose: {
267
+ activeItem: getActiveItem(),
268
+ selectedItems: getSelectedItems(),
269
+ filterQuery: filterQueryRef.current,
270
+ },
271
+ ...(optionsRef.current.onCancel ? { exit: cancel } : {}),
272
+ }))
273
+
274
+ useActions(options.commands)
275
+
276
+ const enabled = useCallback(
277
+ (group: ChooseCommandGroup) => !(optionsRef.current.disable?.includes(group) ?? false),
278
+ [],
279
+ )
280
+
281
+ useCommand({
282
+ id: `${commandScope}:escape`,
283
+ title: "Back / cancel",
284
+ hotkey: "Escape",
285
+ modes: ["insert", "cursor"],
286
+ hidden: true,
287
+ when: () =>
288
+ modeRef.current === "insert"
289
+ ? enabled("mode")
290
+ : enabled("cancel") && optionsRef.current.onCancel !== undefined,
291
+ handler: () => {
292
+ if (modeRef.current === "insert") setMode("cursor")
293
+ else cancel()
294
+ },
295
+ })
296
+ useCommand({
297
+ id: `${commandScope}:cancel`,
298
+ title: "Cancel",
299
+ hotkey: "q",
300
+ modes: ["cursor"],
301
+ hidden: true,
302
+ when: () => enabled("cancel") && optionsRef.current.onCancel !== undefined,
303
+ handler: cancel,
304
+ })
305
+ useCommand({
306
+ id: `${commandScope}:insert-mode-i`,
307
+ title: "Insert mode",
308
+ hotkey: "i",
309
+ modes: ["cursor"],
310
+ hidden: true,
311
+ when: () => enabled("mode"),
312
+ handler: () => setMode("insert"),
313
+ })
314
+ useCommand({
315
+ id: `${commandScope}:insert-mode-a`,
316
+ title: "Insert mode",
317
+ hotkey: "a",
318
+ modes: ["cursor"],
319
+ hidden: true,
320
+ when: () => enabled("mode"),
321
+ handler: () => setMode("insert"),
322
+ })
323
+ useCommand({
324
+ id: `${commandScope}:move-down-vim`,
325
+ title: "Move down",
326
+ hotkey: "j",
327
+ modes: ["cursor"],
328
+ hidden: true,
329
+ when: () => enabled("navigation"),
330
+ handler: moveDown,
331
+ })
332
+ useCommand({
333
+ id: `${commandScope}:move-up-vim`,
334
+ title: "Move up",
335
+ hotkey: "k",
336
+ modes: ["cursor"],
337
+ hidden: true,
338
+ when: () => enabled("navigation"),
339
+ handler: moveUp,
340
+ })
341
+ useCommand({
342
+ id: `${commandScope}:confirm`,
343
+ title: "Confirm",
344
+ hotkey: "Enter",
345
+ modes: ["insert", "cursor"],
346
+ hidden: true,
347
+ when: () => enabled("submit"),
348
+ handler: submit,
349
+ })
350
+ useCommand({
351
+ id: `${commandScope}:move-up`,
352
+ title: "Move up",
353
+ hotkey: "up",
354
+ modes: ["insert", "cursor"],
355
+ hidden: true,
356
+ when: () => enabled("navigation"),
357
+ handler: moveUp,
358
+ })
359
+ useCommand({
360
+ id: `${commandScope}:move-up-ctrl-p`,
361
+ title: "Move up",
362
+ hotkey: "ctrl+p",
363
+ modes: ["insert", "cursor"],
364
+ hidden: true,
365
+ when: () => enabled("navigation"),
366
+ handler: moveUp,
367
+ })
368
+ useCommand({
369
+ id: `${commandScope}:move-down`,
370
+ title: "Move down",
371
+ hotkey: "down",
372
+ modes: ["insert", "cursor"],
373
+ hidden: true,
374
+ when: () => enabled("navigation"),
375
+ handler: moveDown,
376
+ })
377
+ useCommand({
378
+ id: `${commandScope}:move-down-ctrl-n`,
379
+ title: "Move down",
380
+ hotkey: "ctrl+n",
381
+ modes: ["insert", "cursor"],
382
+ hidden: true,
383
+ when: () => enabled("navigation"),
384
+ handler: moveDown,
385
+ })
386
+ useCommand({
387
+ id: `${commandScope}:toggle-next`,
388
+ title: "Toggle selection and move down",
389
+ hotkey: "Tab",
390
+ modes: ["insert", "cursor"],
391
+ hidden: true,
392
+ when: () => enabled("multi-select") && multiRef.current,
393
+ handler: () => {
394
+ toggleActive()
395
+ moveDown()
396
+ },
397
+ })
398
+ useCommand({
399
+ id: `${commandScope}:toggle-previous`,
400
+ title: "Toggle selection and move up",
401
+ hotkey: "shift+Tab",
402
+ modes: ["insert", "cursor"],
403
+ hidden: true,
404
+ when: () => enabled("multi-select") && multiRef.current,
405
+ handler: () => {
406
+ toggleActive()
407
+ moveUp()
408
+ },
409
+ })
410
+
411
+ const surfaceId = useCommandSurfaceId()
412
+ const activeSurface = useActiveCommandSurface()
413
+ const suspended =
414
+ (options.suspended ?? false) || (activeSurface !== null && activeSurface.id !== surfaceId)
415
+
416
+ const onFilterKeyDown = useCallback(
417
+ (event: KeyEvent) => {
418
+ if (
419
+ event.name !== "tab" ||
420
+ !multiRef.current ||
421
+ !enabled("multi-select") ||
422
+ modeRef.current !== "insert"
423
+ ) {
424
+ return
425
+ }
426
+ event.preventDefault()
427
+ toggleActive()
428
+ if (event.shift) moveUp()
429
+ else moveDown()
430
+ },
431
+ [enabled, moveDown, moveUp, toggleActive],
432
+ )
433
+
434
+ const controllerRef = useRef<ChooseController | null>(null)
435
+ if (controllerRef.current === null) {
436
+ controllerRef.current = {
437
+ getFilter: () => filterQueryRef.current,
438
+ setFilter,
439
+ clearFilter: () => setFilter(""),
440
+ moveUp,
441
+ moveDown,
442
+ setActiveIndex,
443
+ getActiveItem,
444
+ getSelectedItems,
445
+ toggleActive,
446
+ submit,
447
+ cancel,
448
+ reload,
449
+ get mode() {
450
+ return modeRef.current
451
+ },
452
+ setMode: setModeExternal,
453
+ }
454
+ }
455
+
456
+ return {
457
+ controller: controllerRef.current,
458
+ state: {
459
+ items,
460
+ matches,
461
+ filterQuery,
462
+ activeIndex,
463
+ activeItem,
464
+ selectedOriginalIndices,
465
+ selectedItems,
466
+ loading,
467
+ error,
468
+ multi,
469
+ },
470
+ view: {
471
+ filterRef,
472
+ mode,
473
+ suspended,
474
+ filterFocused: mode === "insert" && !suspended,
475
+ onFilterInput: setFilter,
476
+ onFilterKeyDown,
477
+ },
478
+ }
479
+ }