@tooee/choose 0.7.3 → 0.9.0

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 (62) hide show
  1. package/dist/choose-filter.d.ts +2 -11
  2. package/dist/choose-filter.d.ts.map +1 -1
  3. package/dist/choose-filter.js +1 -10
  4. package/dist/choose-filter.js.map +1 -1
  5. package/dist/choose-highlighted-text.d.ts +2 -8
  6. package/dist/choose-highlighted-text.d.ts.map +1 -1
  7. package/dist/choose-highlighted-text.js +1 -31
  8. package/dist/choose-highlighted-text.js.map +1 -1
  9. package/dist/choose-list.d.ts +2 -27
  10. package/dist/choose-list.d.ts.map +1 -1
  11. package/dist/choose-list.js +1 -56
  12. package/dist/choose-list.js.map +1 -1
  13. package/dist/choose-panel.d.ts +7 -9
  14. package/dist/choose-panel.d.ts.map +1 -1
  15. package/dist/choose-panel.js +5 -8
  16. package/dist/choose-panel.js.map +1 -1
  17. package/dist/choose-store.d.ts +3 -0
  18. package/dist/choose-store.d.ts.map +1 -0
  19. package/dist/choose-store.js +2 -0
  20. package/dist/choose-store.js.map +1 -0
  21. package/dist/choose.d.ts +3 -7
  22. package/dist/choose.d.ts.map +1 -1
  23. package/dist/choose.js +10 -12
  24. package/dist/choose.js.map +1 -1
  25. package/dist/choose.public-contract.typecheck.d.ts +11 -0
  26. package/dist/choose.public-contract.typecheck.d.ts.map +1 -0
  27. package/dist/choose.public-contract.typecheck.js +2 -0
  28. package/dist/choose.public-contract.typecheck.js.map +1 -0
  29. package/dist/fuzzy.d.ts +2 -8
  30. package/dist/fuzzy.d.ts.map +1 -1
  31. package/dist/fuzzy.js +1 -16
  32. package/dist/fuzzy.js.map +1 -1
  33. package/dist/launch.d.ts.map +1 -1
  34. package/dist/launch.js +2 -1
  35. package/dist/launch.js.map +1 -1
  36. package/dist/source.d.ts +1 -4
  37. package/dist/source.d.ts.map +1 -1
  38. package/dist/source.js +1 -13
  39. package/dist/source.js.map +1 -1
  40. package/dist/types.d.ts +1 -21
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/use-choose-dialog.d.ts.map +1 -1
  43. package/dist/use-choose-dialog.js +22 -84
  44. package/dist/use-choose-dialog.js.map +1 -1
  45. package/dist/use-choose.d.ts +2 -79
  46. package/dist/use-choose.d.ts.map +1 -1
  47. package/dist/use-choose.js +1 -402
  48. package/dist/use-choose.js.map +1 -1
  49. package/package.json +8 -7
  50. package/src/choose-filter.tsx +2 -47
  51. package/src/choose-highlighted-text.tsx +2 -49
  52. package/src/choose-list.tsx +2 -148
  53. package/src/choose-panel.tsx +18 -71
  54. package/src/choose-store.ts +12 -0
  55. package/src/choose.public-contract.typecheck.ts +12 -0
  56. package/src/choose.tsx +20 -24
  57. package/src/fuzzy.ts +2 -28
  58. package/src/launch.tsx +8 -2
  59. package/src/source.ts +1 -18
  60. package/src/types.ts +7 -28
  61. package/src/use-choose-dialog.tsx +30 -98
  62. package/src/use-choose.ts +9 -526
package/src/use-choose.ts CHANGED
@@ -1,526 +1,9 @@
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
- } from "@tooee/commands";
12
- import type { ActionDefinition, Mode } from "@tooee/commands";
13
- import { fuzzyFilter } from "./fuzzy.js";
14
- import 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 const useChoose = 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, setActiveIndex] = 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) {
117
- return activeItem === undefined ? [] : [activeItem];
118
- }
119
- const selected = [...selectedOriginalIndices].flatMap((index) => {
120
- const item = items[index];
121
- return item === undefined ? [] : [item];
122
- });
123
- if (selected.length > 0) {
124
- return selected;
125
- }
126
- return activeItem === undefined ? [] : [activeItem];
127
- }, [multi, activeItem, selectedOriginalIndices, items]);
128
-
129
- const mode = useMode();
130
- const setMode = useSetMode();
131
-
132
- // Stable controller methods read live values through refs, including when
133
- // multiple imperative operations happen before React renders again.
134
- const optionsRef = useRef(options);
135
- optionsRef.current = options;
136
- const itemsRef = useRef(items);
137
- itemsRef.current = items;
138
- const matchesRef = useRef(matches);
139
- matchesRef.current = matches;
140
- const filterQueryRef = useRef(filterQuery);
141
- filterQueryRef.current = filterQuery;
142
- const activeIndexRef = useRef(activeIndex);
143
- activeIndexRef.current = activeIndex;
144
- const selectedRef = useRef(selectedOriginalIndices);
145
- selectedRef.current = selectedOriginalIndices;
146
- const multiRef = useRef(multi);
147
- multiRef.current = multi;
148
- const modeRef = useRef(mode);
149
- modeRef.current = mode;
150
-
151
- const replaceItems = useCallback((nextItems: ChooseItem[]) => {
152
- itemsRef.current = nextItems;
153
- matchesRef.current = fuzzyFilter(nextItems, filterQueryRef.current);
154
- activeIndexRef.current = 0;
155
- selectedRef.current = new Set();
156
- setItems(nextItems);
157
- setActiveIndex(0);
158
- setSelectedOriginalIndices(selectedRef.current);
159
- }, []);
160
-
161
- useEffect(() => {
162
- requestIdRef.current += 1;
163
- const requestId = requestIdRef.current;
164
- let active = true;
165
- const deactivate = () => {
166
- active = false;
167
- };
168
- let result: ChooseItem[] | Promise<ChooseItem[]>;
169
-
170
- // Direct arrays seed state during the first render. Avoid a redundant
171
- // post-mount reset that could overwrite immediate controller operations.
172
- if (
173
- !didHandleInitialSourceRef.current &&
174
- Array.isArray(source) &&
175
- source === initialArraySourceRef.current
176
- ) {
177
- didHandleInitialSourceRef.current = true;
178
- setLoading(false);
179
- return deactivate;
180
- }
181
- didHandleInitialSourceRef.current = true;
182
-
183
- setError(null);
184
- try {
185
- result = loadChooseSource(source);
186
- } catch (loadError) {
187
- if (requestId !== requestIdRef.current) {
188
- return deactivate;
189
- }
190
- replaceItems([]);
191
- setError(chooseSourceError(loadError));
192
- setLoading(false);
193
- return deactivate;
194
- }
195
-
196
- if (result instanceof Promise) {
197
- setLoading(true);
198
- void (async () => {
199
- try {
200
- const loaded = await result;
201
- if (!active || requestId !== requestIdRef.current) {
202
- return;
203
- }
204
- replaceItems(loaded);
205
- setError(null);
206
- setLoading(false);
207
- } catch (loadError) {
208
- if (!active || requestId !== requestIdRef.current) {
209
- return;
210
- }
211
- replaceItems([]);
212
- setError(chooseSourceError(loadError));
213
- setLoading(false);
214
- }
215
- })();
216
- return deactivate;
217
- }
218
-
219
- replaceItems(result);
220
- setLoading(false);
221
- return deactivate;
222
- }, [source, reloadRevision, replaceItems]);
223
-
224
- const setFilter = useCallback((query: string) => {
225
- // Controlled OpenTUI inputs may echo a programmatic `value` update through
226
- // onInput. Only a real query change resets navigation.
227
- if (query === filterQueryRef.current) {
228
- setFilterQuery(query);
229
- return;
230
- }
231
- filterQueryRef.current = query;
232
- matchesRef.current = fuzzyFilter(itemsRef.current, query);
233
- activeIndexRef.current = 0;
234
- setFilterQuery(query);
235
- setActiveIndex(0);
236
- }, []);
237
-
238
- const updateActiveIndex = useCallback((index: number) => {
239
- const lastIndex = Math.max(0, matchesRef.current.length - 1);
240
- const next = Math.min(lastIndex, Math.max(0, index));
241
- activeIndexRef.current = next;
242
- setActiveIndex(next);
243
- }, []);
244
-
245
- const moveUp = useCallback(() => {
246
- updateActiveIndex(activeIndexRef.current - 1);
247
- }, [updateActiveIndex]);
248
- const moveDown = useCallback(() => {
249
- updateActiveIndex(activeIndexRef.current + 1);
250
- }, [updateActiveIndex]);
251
-
252
- const getActiveItem = useCallback(() => matchesRef.current[activeIndexRef.current]?.item, []);
253
-
254
- const getSelectedItems = useCallback((): ChooseItem[] => {
255
- const active = getActiveItem();
256
- if (!multiRef.current) {
257
- return active === undefined ? [] : [active];
258
- }
259
- const selected = [...selectedRef.current].flatMap((index) => {
260
- const item = itemsRef.current[index];
261
- return item === undefined ? [] : [item];
262
- });
263
- if (selected.length > 0) {
264
- return selected;
265
- }
266
- return active === undefined ? [] : [active];
267
- }, [getActiveItem]);
268
-
269
- const toggleActive = useCallback(() => {
270
- if (!multiRef.current) {
271
- return;
272
- }
273
- const originalIndex = matchesRef.current[activeIndexRef.current]?.originalIndex;
274
- if (originalIndex === undefined) {
275
- return;
276
- }
277
- const next = new Set(selectedRef.current);
278
- if (next.has(originalIndex)) {
279
- next.delete(originalIndex);
280
- } else {
281
- next.add(originalIndex);
282
- }
283
- selectedRef.current = next;
284
- setSelectedOriginalIndices(next);
285
- }, []);
286
-
287
- const submit = useCallback(() => {
288
- void optionsRef.current.onSubmit({ items: getSelectedItems() });
289
- }, [getSelectedItems]);
290
-
291
- const cancel = useCallback(() => optionsRef.current.onCancel?.(), []);
292
- const reload = useCallback(() => {
293
- setReloadRevision((revision) => revision + 1);
294
- }, []);
295
- const setModeExternal = useCallback(
296
- (nextMode: Mode) => {
297
- setMode(nextMode);
298
- },
299
- [setMode],
300
- );
301
-
302
- useProvideCommandContext(() => ({
303
- choose: {
304
- activeItem: getActiveItem(),
305
- filterQuery: filterQueryRef.current,
306
- selectedItems: getSelectedItems(),
307
- },
308
- ...(optionsRef.current.onCancel ? { exit: cancel } : {}),
309
- }));
310
-
311
- useActions(options.commands);
312
-
313
- const enabled = useCallback(
314
- (group: ChooseCommandGroup) => !(optionsRef.current.disable?.includes(group) ?? false),
315
- [],
316
- );
317
-
318
- useCommand({
319
- handler: () => {
320
- if (modeRef.current === "insert") {
321
- setMode("cursor");
322
- } else {
323
- cancel();
324
- }
325
- },
326
- hidden: true,
327
- hotkey: "Escape",
328
- id: `${commandScope}:escape`,
329
- modes: ["insert", "cursor"],
330
- title: "Back / cancel",
331
- when: () =>
332
- modeRef.current === "insert"
333
- ? enabled("mode")
334
- : enabled("cancel") && optionsRef.current.onCancel !== undefined,
335
- });
336
- useCommand({
337
- handler: cancel,
338
- hidden: true,
339
- hotkey: "q",
340
- id: `${commandScope}:cancel`,
341
- modes: ["cursor"],
342
- title: "Cancel",
343
- when: () => enabled("cancel") && optionsRef.current.onCancel !== undefined,
344
- });
345
- useCommand({
346
- handler: () => {
347
- setMode("insert");
348
- },
349
- hidden: true,
350
- hotkey: "i",
351
- id: `${commandScope}:insert-mode-i`,
352
- modes: ["cursor"],
353
- title: "Insert mode",
354
- when: () => enabled("mode"),
355
- });
356
- useCommand({
357
- handler: () => {
358
- setMode("insert");
359
- },
360
- hidden: true,
361
- hotkey: "a",
362
- id: `${commandScope}:insert-mode-a`,
363
- modes: ["cursor"],
364
- title: "Insert mode",
365
- when: () => enabled("mode"),
366
- });
367
- useCommand({
368
- handler: moveDown,
369
- hidden: true,
370
- hotkey: "j",
371
- id: `${commandScope}:move-down-vim`,
372
- modes: ["cursor"],
373
- title: "Move down",
374
- when: () => enabled("navigation"),
375
- });
376
- useCommand({
377
- handler: moveUp,
378
- hidden: true,
379
- hotkey: "k",
380
- id: `${commandScope}:move-up-vim`,
381
- modes: ["cursor"],
382
- title: "Move up",
383
- when: () => enabled("navigation"),
384
- });
385
- useCommand({
386
- handler: submit,
387
- hidden: true,
388
- hotkey: "Enter",
389
- id: `${commandScope}:confirm`,
390
- modes: ["insert", "cursor"],
391
- title: "Confirm",
392
- when: () => enabled("submit"),
393
- });
394
- useCommand({
395
- handler: moveUp,
396
- hidden: true,
397
- hotkey: "up",
398
- id: `${commandScope}:move-up`,
399
- modes: ["insert", "cursor"],
400
- title: "Move up",
401
- when: () => enabled("navigation"),
402
- });
403
- useCommand({
404
- handler: moveUp,
405
- hidden: true,
406
- hotkey: "ctrl+p",
407
- id: `${commandScope}:move-up-ctrl-p`,
408
- modes: ["insert", "cursor"],
409
- title: "Move up",
410
- when: () => enabled("navigation"),
411
- });
412
- useCommand({
413
- handler: moveDown,
414
- hidden: true,
415
- hotkey: "down",
416
- id: `${commandScope}:move-down`,
417
- modes: ["insert", "cursor"],
418
- title: "Move down",
419
- when: () => enabled("navigation"),
420
- });
421
- useCommand({
422
- handler: moveDown,
423
- hidden: true,
424
- hotkey: "ctrl+n",
425
- id: `${commandScope}:move-down-ctrl-n`,
426
- modes: ["insert", "cursor"],
427
- title: "Move down",
428
- when: () => enabled("navigation"),
429
- });
430
- useCommand({
431
- handler: () => {
432
- toggleActive();
433
- moveDown();
434
- },
435
- hidden: true,
436
- hotkey: "Tab",
437
- id: `${commandScope}:toggle-next`,
438
- modes: ["insert", "cursor"],
439
- title: "Toggle selection and move down",
440
- when: () => enabled("multi-select") && multiRef.current,
441
- });
442
- useCommand({
443
- handler: () => {
444
- toggleActive();
445
- moveUp();
446
- },
447
- hidden: true,
448
- hotkey: "shift+Tab",
449
- id: `${commandScope}:toggle-previous`,
450
- modes: ["insert", "cursor"],
451
- title: "Toggle selection and move up",
452
- when: () => enabled("multi-select") && multiRef.current,
453
- });
454
-
455
- const surfaceId = useCommandSurfaceId();
456
- const activeSurface = useActiveCommandSurface();
457
- const suspended =
458
- (options.suspended ?? false) || (activeSurface !== null && activeSurface.id !== surfaceId);
459
-
460
- const onFilterKeyDown = useCallback(
461
- (event: KeyEvent) => {
462
- if (
463
- event.name !== "tab" ||
464
- !multiRef.current ||
465
- !enabled("multi-select") ||
466
- modeRef.current !== "insert"
467
- ) {
468
- return;
469
- }
470
- event.preventDefault();
471
- toggleActive();
472
- if (event.shift) {
473
- moveUp();
474
- } else {
475
- moveDown();
476
- }
477
- },
478
- [enabled, moveDown, moveUp, toggleActive],
479
- );
480
-
481
- const controllerRef = useRef<ChooseController | null>(null);
482
- controllerRef.current ??= {
483
- cancel,
484
- clearFilter: () => {
485
- setFilter("");
486
- },
487
- getActiveItem,
488
- getFilter: () => filterQueryRef.current,
489
- getSelectedItems,
490
- get mode() {
491
- return modeRef.current;
492
- },
493
- moveDown,
494
- moveUp,
495
- reload,
496
- setActiveIndex: updateActiveIndex,
497
- setFilter,
498
- setMode: setModeExternal,
499
- submit,
500
- toggleActive,
501
- };
502
-
503
- return {
504
- controller: controllerRef.current,
505
- state: {
506
- activeIndex,
507
- activeItem,
508
- error,
509
- filterQuery,
510
- items,
511
- loading,
512
- matches,
513
- multi,
514
- selectedItems,
515
- selectedOriginalIndices,
516
- },
517
- view: {
518
- filterFocused: mode === "insert" && !suspended,
519
- filterRef,
520
- mode,
521
- onFilterInput: setFilter,
522
- onFilterKeyDown,
523
- suspended,
524
- },
525
- };
526
- };
1
+ export { useChoose } from "@tooee/renderers";
2
+ export type {
3
+ ChooseCommandGroup,
4
+ ChooseController,
5
+ ChooseState,
6
+ ChooseViewModel,
7
+ UseChooseOptions,
8
+ UseChooseResult,
9
+ } from "@tooee/renderers";