inquirerjs-checkbox-search 0.1.2

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.
@@ -0,0 +1,437 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Separator = void 0;
7
+ const core_1 = require("@inquirer/core");
8
+ const yoctocolors_cjs_1 = __importDefault(require("yoctocolors-cjs"));
9
+ const figures_1 = __importDefault(require("@inquirer/figures"));
10
+ const ansi_escapes_1 = __importDefault(require("ansi-escapes"));
11
+ /**
12
+ * Default theme for the checkbox-search prompt
13
+ */
14
+ const checkboxSearchTheme = {
15
+ icon: {
16
+ checked: yoctocolors_cjs_1.default.green(figures_1.default.circleFilled),
17
+ unchecked: figures_1.default.circle,
18
+ cursor: figures_1.default.pointer,
19
+ },
20
+ style: {
21
+ answer: yoctocolors_cjs_1.default.cyan,
22
+ message: yoctocolors_cjs_1.default.cyan,
23
+ error: (text) => yoctocolors_cjs_1.default.yellow(`> ${text}`),
24
+ help: yoctocolors_cjs_1.default.dim,
25
+ highlight: yoctocolors_cjs_1.default.cyan,
26
+ searchTerm: yoctocolors_cjs_1.default.cyan,
27
+ description: yoctocolors_cjs_1.default.dim,
28
+ disabled: yoctocolors_cjs_1.default.dim,
29
+ },
30
+ helpMode: 'always',
31
+ };
32
+ /**
33
+ * Type guard to check if an item is selectable (not separator or disabled)
34
+ * @param item - The item to check
35
+ * @returns True if the item can be selected
36
+ */
37
+ function isSelectable(item) {
38
+ return !core_1.Separator.isSeparator(item) && !item.disabled;
39
+ }
40
+ /**
41
+ * Type guard to check if an item is checked/selected
42
+ * @param item - The item to check
43
+ * @returns True if the item is selected
44
+ */
45
+ function isChecked(item) {
46
+ return isSelectable(item) && Boolean(item.checked);
47
+ }
48
+ /**
49
+ * Toggle the selection state of an item
50
+ * @param item - The item to toggle
51
+ * @returns The item with toggled selection state
52
+ */
53
+ function toggle(item) {
54
+ return isSelectable(item) ? { ...item, checked: !item.checked } : item;
55
+ }
56
+ /**
57
+ * Normalize choice inputs into consistent format
58
+ * @param choices - Raw choices array (strings, choice objects, separators)
59
+ * @returns Normalized array of items
60
+ */
61
+ function normalizeChoices(choices) {
62
+ return choices.map((choice) => {
63
+ if (core_1.Separator.isSeparator(choice))
64
+ return choice;
65
+ if (typeof choice === 'string') {
66
+ return {
67
+ value: choice,
68
+ name: choice,
69
+ short: choice,
70
+ disabled: false,
71
+ checked: false,
72
+ };
73
+ }
74
+ const name = choice.name ?? String(choice.value);
75
+ const normalizedChoice = {
76
+ value: choice.value,
77
+ name,
78
+ short: choice.short ?? name,
79
+ disabled: choice.disabled ?? false,
80
+ checked: choice.checked ?? false,
81
+ };
82
+ if (choice.description) {
83
+ normalizedChoice.description = choice.description;
84
+ }
85
+ return normalizedChoice;
86
+ });
87
+ }
88
+ /**
89
+ * Default filter function for static choices
90
+ * @param items - Array of normalized choices
91
+ * @param term - Search term
92
+ * @returns Filtered array of choices
93
+ */
94
+ function defaultFilter(items, term) {
95
+ if (!term.trim())
96
+ return items;
97
+ const searchTerm = term.toLowerCase().normalize('NFD');
98
+ return items.filter((item) => {
99
+ const name = item.name.toLowerCase().normalize('NFD');
100
+ const description = (item.description ?? '').toLowerCase().normalize('NFD');
101
+ const value = String(item.value).toLowerCase().normalize('NFD');
102
+ return (name.includes(searchTerm) ||
103
+ description.includes(searchTerm) ||
104
+ value.includes(searchTerm));
105
+ });
106
+ }
107
+ /**
108
+ * Main checkbox-search prompt implementation
109
+ *
110
+ * A multi-select prompt with text filtering/search capability that combines
111
+ * the functionality of checkbox and search prompts from inquirer.js.
112
+ *
113
+ * Features:
114
+ * - Real-time search/filtering of options
115
+ * - Multi-selection with checkboxes
116
+ * - Keyboard navigation and shortcuts
117
+ * - Support for both static and async data sources
118
+ * - Customizable themes and validation
119
+ *
120
+ * @param config - Configuration options for the prompt
121
+ * @param done - Callback function called when prompt completes
122
+ */
123
+ exports.default = (0, core_1.createPrompt)((config, done) => {
124
+ // Stable reference for empty array to prevent unnecessary recalculations
125
+ const emptyArray = (0, core_1.useMemo)(() => [], []);
126
+ // Configuration with defaults
127
+ const { pageSize = 7, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
128
+ const theme = (0, core_1.makeTheme)(checkboxSearchTheme, config.theme);
129
+ // State management hooks
130
+ const [status, setStatus] = (0, core_1.useState)('idle');
131
+ const prefix = (0, core_1.usePrefix)({ status, theme });
132
+ // Search state
133
+ const [searchTerm, setSearchTerm] = (0, core_1.useState)('');
134
+ const [searchError, setSearchError] = (0, core_1.useState)();
135
+ const allItemsRef = (0, core_1.useRef)([]);
136
+ // Initialize choices directly like the original checkbox prompt
137
+ const [allItems, setAllItems] = (0, core_1.useState)(() => {
138
+ if (config.choices) {
139
+ const normalized = normalizeChoices(config.choices);
140
+ // Apply default selections
141
+ return normalized.map((item) => {
142
+ if (isSelectable(item) && defaultValues.includes(item.value)) {
143
+ return { ...item, checked: true };
144
+ }
145
+ return item;
146
+ });
147
+ }
148
+ return [];
149
+ });
150
+ // Compute filtered items based on search term and filtering logic
151
+ const filteredItems = (0, core_1.useMemo)(() => {
152
+ // Async source mode - use allItems directly (source handles filtering)
153
+ if (config.source) {
154
+ return allItems;
155
+ }
156
+ // Static mode - filter allItems based on search term
157
+ if (!searchTerm.trim()) {
158
+ return allItems;
159
+ }
160
+ // Filter using provided filter function or default case-insensitive filter
161
+ const filterFn = config.filter || defaultFilter;
162
+ const selectableItems = allItems.filter((item) => !core_1.Separator.isSeparator(item));
163
+ const filtered = filterFn(selectableItems, searchTerm);
164
+ // Create a set of filtered values for efficient lookup
165
+ const filteredValues = new Set(filtered.map((item) => item.value));
166
+ // Rebuild preserving current allItems state (including checked status)
167
+ const result = [];
168
+ for (const item of allItems) {
169
+ if (core_1.Separator.isSeparator(item)) {
170
+ result.push(item);
171
+ }
172
+ else if (filteredValues.has(item.value)) {
173
+ result.push(item); // This preserves the current checked state from allItems
174
+ }
175
+ }
176
+ return result;
177
+ }, [allItems, searchTerm, config.source, config.filter]);
178
+ const [active, setActive] = (0, core_1.useState)(0);
179
+ const [errorMsg, setError] = (0, core_1.useState)();
180
+ // Handle async source - load data based on search term
181
+ (0, core_1.useEffect)(() => {
182
+ if (!config.source) {
183
+ return;
184
+ }
185
+ const controller = new AbortController();
186
+ // Set loading state and clear previous errors
187
+ setStatus('loading');
188
+ setSearchError(undefined);
189
+ const result = config.source(searchTerm || undefined, {
190
+ signal: controller.signal,
191
+ });
192
+ // Handle both Promise and non-Promise returns
193
+ Promise.resolve(result)
194
+ .then((choices) => {
195
+ if (controller.signal.aborted)
196
+ return;
197
+ const normalizedChoices = normalizeChoices(choices);
198
+ setAllItems(normalizedChoices);
199
+ setStatus('idle');
200
+ })
201
+ .catch((error) => {
202
+ if (controller.signal.aborted)
203
+ return;
204
+ console.error('Source function error:', error);
205
+ setSearchError(error instanceof Error ? error.message : 'Failed to load choices');
206
+ setStatus('idle');
207
+ });
208
+ return () => {
209
+ controller.abort();
210
+ };
211
+ }, [config.source, searchTerm]);
212
+ // Update ref whenever allItems changes
213
+ (0, core_1.useEffect)(() => {
214
+ allItemsRef.current = allItems;
215
+ }, [allItems]);
216
+ // Reset active index when filtered items change, but preserve cursor position during selection toggles
217
+ (0, core_1.useEffect)(() => {
218
+ // Don't reset cursor position if we're just toggling selection on the same items
219
+ // Only reset when the actual set of filtered items changes (not their selection state)
220
+ const currentFilteredValues = filteredItems
221
+ .filter((item) => !core_1.Separator.isSeparator(item))
222
+ .map((item) => item.value);
223
+ const activeItem = filteredItems[active];
224
+ const activeValue = activeItem && !core_1.Separator.isSeparator(activeItem)
225
+ ? activeItem.value
226
+ : null;
227
+ // If the currently active item is still in the filtered list, preserve its position
228
+ if (activeValue && currentFilteredValues.includes(activeValue)) {
229
+ const newActiveIndex = filteredItems.findIndex((item) => !core_1.Separator.isSeparator(item) &&
230
+ item.value === activeValue);
231
+ if (newActiveIndex !== -1) {
232
+ setActive(newActiveIndex);
233
+ return;
234
+ }
235
+ }
236
+ // Otherwise reset to 0 (when filtering actually changes the set of items)
237
+ setActive(0);
238
+ }, [filteredItems, active]);
239
+ // Keyboard event handling
240
+ (0, core_1.useKeypress)((key, rl) => {
241
+ // Allow search input even during loading, but block other actions
242
+ const isNavigationOrAction = key.name === 'up' ||
243
+ key.name === 'down' ||
244
+ key.name === 'tab' ||
245
+ key.name === 'enter' ||
246
+ key.name === 'escape';
247
+ if (status !== 'idle' && isNavigationOrAction) {
248
+ return;
249
+ }
250
+ // Clear any existing errors
251
+ setError(undefined);
252
+ // Handle Escape key - clear search term quickly
253
+ if (key.name === 'escape') {
254
+ rl.line = ''; // Clear readline input first (avoid re-render)
255
+ setSearchTerm(''); // Then update state
256
+ return;
257
+ }
258
+ // Handle navigation
259
+ if ((0, core_1.isUpKey)(key) || (0, core_1.isDownKey)(key)) {
260
+ const direction = (0, core_1.isUpKey)(key) ? -1 : 1;
261
+ const selectableIndexes = filteredItems
262
+ .map((item, index) => ({ item, index }))
263
+ .filter(({ item }) => isSelectable(item))
264
+ .map(({ index }) => index);
265
+ if (selectableIndexes.length === 0)
266
+ return;
267
+ const currentSelectableIndex = selectableIndexes.findIndex((index) => index >= active);
268
+ let nextSelectableIndex = currentSelectableIndex + direction;
269
+ if (loop) {
270
+ if (nextSelectableIndex < 0)
271
+ nextSelectableIndex = selectableIndexes.length - 1;
272
+ if (nextSelectableIndex >= selectableIndexes.length)
273
+ nextSelectableIndex = 0;
274
+ }
275
+ else {
276
+ nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
277
+ }
278
+ setActive(selectableIndexes[nextSelectableIndex] || 0);
279
+ return;
280
+ }
281
+ // Handle selection toggle with tab key ONLY - prevent tab from affecting search text
282
+ if (key.name === 'tab') {
283
+ const activeItem = filteredItems[active];
284
+ if (activeItem && isSelectable(activeItem)) {
285
+ const activeValue = activeItem.value;
286
+ setAllItems(allItems.map((item) => {
287
+ // Compare by value only for robust matching
288
+ if (!core_1.Separator.isSeparator(item) && item.value === activeValue) {
289
+ const toggled = toggle(item);
290
+ return toggled;
291
+ }
292
+ return item;
293
+ }));
294
+ }
295
+ return; // Important: return here to prevent tab from being added to search term
296
+ }
297
+ // Handle submission
298
+ if ((0, core_1.isEnterKey)(key)) {
299
+ const selectedChoices = allItems.filter(isChecked);
300
+ if (required && selectedChoices.length === 0) {
301
+ setError('At least one choice must be selected');
302
+ return;
303
+ }
304
+ const result = validate(selectedChoices);
305
+ if (typeof result === 'string') {
306
+ setError(result);
307
+ return;
308
+ }
309
+ if (result === false) {
310
+ setError('Invalid selection');
311
+ return;
312
+ }
313
+ if (typeof result === 'object' && 'then' in result) {
314
+ result
315
+ .then((isValid) => {
316
+ if (typeof isValid === 'string') {
317
+ setError(isValid);
318
+ }
319
+ else if (isValid === false) {
320
+ setError('Invalid selection');
321
+ }
322
+ else {
323
+ setStatus('done');
324
+ done(selectedChoices.map((choice) => choice.value));
325
+ }
326
+ })
327
+ .catch(() => {
328
+ setError('Validation failed');
329
+ });
330
+ return;
331
+ }
332
+ setStatus('done');
333
+ done(selectedChoices.map((choice) => choice.value));
334
+ return;
335
+ }
336
+ // Handle all other input as search term updates EXCEPT tab
337
+ // Only update search term for actual typing, not navigation keys
338
+ if (!isNavigationOrAction) {
339
+ setSearchTerm(rl.line);
340
+ }
341
+ });
342
+ // Create renderItem function that's reactive to current state
343
+ const renderItem = (0, core_1.useMemo)(() => {
344
+ return ({ item, isActive }) => {
345
+ const line = [];
346
+ if (core_1.Separator.isSeparator(item)) {
347
+ return yoctocolors_cjs_1.default.dim(item.separator);
348
+ }
349
+ // Look up checked state directly from allItems to get the current state
350
+ const currentItem = allItems.find((allItem) => !core_1.Separator.isSeparator(allItem) &&
351
+ allItem.value === item.value);
352
+ const isChecked = currentItem?.checked || false;
353
+ // Helper function to resolve icon (string or function)
354
+ const resolveIcon = (icon, choiceText) => {
355
+ return typeof icon === 'function' ? icon(choiceText) : icon;
356
+ };
357
+ const choiceName = item.name;
358
+ const checkbox = resolveIcon(isChecked ? theme.icon.checked : theme.icon.unchecked, choiceName);
359
+ const cursor = isActive
360
+ ? resolveIcon(theme.icon.cursor, choiceName)
361
+ : ' ';
362
+ line.push(cursor, checkbox);
363
+ let text = item.name;
364
+ if (isActive) {
365
+ text = theme.style.highlight(text);
366
+ }
367
+ else if (item.disabled) {
368
+ text = theme.style.disabled(text);
369
+ }
370
+ line.push(text);
371
+ // Show disabled reason if item is disabled
372
+ if (item.disabled) {
373
+ const disabledReason = typeof item.disabled === 'string'
374
+ ? item.disabled
375
+ : 'disabled';
376
+ line.push(theme.style.disabled(`(${disabledReason})`));
377
+ }
378
+ else if (item.description) {
379
+ const description = item.description;
380
+ // If using custom description styling, give full control to user (no parentheses)
381
+ // If using default styling, add parentheses for backward compatibility
382
+ const isUsingCustomDescriptionStyle = config.theme?.style?.description !== undefined;
383
+ if (description) {
384
+ if (isUsingCustomDescriptionStyle) {
385
+ line.push(theme.style.description(description));
386
+ }
387
+ else {
388
+ line.push(`(${theme.style.description(description)})`);
389
+ }
390
+ }
391
+ }
392
+ return line.join(' ');
393
+ };
394
+ }, [allItems, theme, config.theme]);
395
+ // Setup pagination
396
+ const page = (0, core_1.usePagination)({
397
+ items: filteredItems,
398
+ active,
399
+ renderItem,
400
+ pageSize,
401
+ loop,
402
+ });
403
+ // Render the prompt
404
+ const message = theme.style.message(config.message, status);
405
+ let helpTip = '';
406
+ if (theme.helpMode === 'always') {
407
+ const tips = ['Tab to select', 'Enter to submit'];
408
+ helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
409
+ }
410
+ let searchLine = '';
411
+ if (config.source || searchTerm || status === 'loading') {
412
+ const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
413
+ const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
414
+ searchLine = `\n${searchPrefix} ${styledTerm}`;
415
+ }
416
+ let errorLine = '';
417
+ if (errorMsg) {
418
+ errorLine = `\n${theme.style.error(errorMsg)}`;
419
+ }
420
+ if (searchError) {
421
+ errorLine = `\n${theme.style.error(`Error: ${searchError}`)}`;
422
+ }
423
+ let content = '';
424
+ if (status === 'loading') {
425
+ content = '\nLoading choices...';
426
+ }
427
+ else if (filteredItems.length === 0) {
428
+ content = '\nNo choices available';
429
+ }
430
+ else {
431
+ content = `\n${page}`;
432
+ }
433
+ return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${ansi_escapes_1.default.cursorHide}`;
434
+ });
435
+ // Re-export Separator for convenience
436
+ var core_2 = require("@inquirer/core");
437
+ Object.defineProperty(exports, "Separator", { enumerable: true, get: function () { return core_2.Separator; } });
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,82 @@
1
+ import { Separator, type Theme } from '@inquirer/core';
2
+ import type { PartialDeep } from '@inquirer/type';
3
+ /**
4
+ * Theme configuration for the checkbox-search prompt
5
+ */
6
+ type CheckboxSearchTheme = {
7
+ icon: {
8
+ checked: string | ((text: string) => string);
9
+ unchecked: string | ((text: string) => string);
10
+ cursor: string | ((text: string) => string);
11
+ };
12
+ style: {
13
+ answer: (text: string) => string;
14
+ message: (text: string) => string;
15
+ error: (text: string) => string;
16
+ help: (text: string) => string;
17
+ highlight: (text: string) => string;
18
+ searchTerm: (text: string) => string;
19
+ description: (text: string) => string;
20
+ disabled: (text: string) => string;
21
+ };
22
+ helpMode: 'always' | 'never' | 'auto';
23
+ };
24
+ /**
25
+ * Choice object for the checkbox-search prompt
26
+ */
27
+ export type Choice<Value> = {
28
+ value: Value;
29
+ name?: string;
30
+ description?: string;
31
+ short?: string;
32
+ disabled?: boolean | string;
33
+ checked?: boolean;
34
+ type?: never;
35
+ };
36
+ /**
37
+ * Normalized choice object used internally
38
+ */
39
+ export type NormalizedChoice<Value> = {
40
+ value: Value;
41
+ name: string;
42
+ description?: string;
43
+ short: string;
44
+ disabled: boolean | string;
45
+ checked: boolean;
46
+ };
47
+ /**
48
+ * Main checkbox-search prompt implementation
49
+ *
50
+ * A multi-select prompt with text filtering/search capability that combines
51
+ * the functionality of checkbox and search prompts from inquirer.js.
52
+ *
53
+ * Features:
54
+ * - Real-time search/filtering of options
55
+ * - Multi-selection with checkboxes
56
+ * - Keyboard navigation and shortcuts
57
+ * - Support for both static and async data sources
58
+ * - Customizable themes and validation
59
+ *
60
+ * @param config - Configuration options for the prompt
61
+ * @param done - Callback function called when prompt completes
62
+ */
63
+ declare const _default: <Value>(config: {
64
+ message: string;
65
+ prefix?: string | undefined;
66
+ pageSize?: number | undefined;
67
+ instructions?: string | boolean | undefined;
68
+ choices?: readonly (string | Separator)[] | readonly (Separator | Choice<Value>)[] | undefined;
69
+ source?: ((term: string | undefined, opt: {
70
+ signal: AbortSignal;
71
+ }) => readonly (string | Separator)[] | readonly (Separator | Choice<Value>)[] | Promise<readonly (string | Separator)[]> | Promise<readonly (Separator | Choice<Value>)[]>) | undefined;
72
+ filter?: ((items: readonly NormalizedChoice<Value>[], term: string) => readonly NormalizedChoice<Value>[]) | undefined;
73
+ loop?: boolean | undefined;
74
+ required?: boolean | undefined;
75
+ validate?: ((choices: readonly NormalizedChoice<Value>[]) => boolean | string | Promise<string | boolean>) | undefined;
76
+ theme?: PartialDeep<Theme<CheckboxSearchTheme>> | undefined;
77
+ default?: readonly Value[] | undefined;
78
+ }, context?: import("@inquirer/type").Context) => Promise<Value[]> & {
79
+ cancel: () => void;
80
+ };
81
+ export default _default;
82
+ export { Separator } from '@inquirer/core';