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