inquirerjs-checkbox-search 0.1.2 → 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.
- package/README.md +16 -43
- package/dist/commonjs/index.d.ts +6 -0
- package/dist/commonjs/index.js +142 -54
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.js +142 -55
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -10,36 +10,9 @@ This prompt combines the functionality of `@inquirer/checkbox` and `@inquirer/se
|
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<th>yarn</th>
|
|
17
|
-
<th>pnpm</th>
|
|
18
|
-
</tr>
|
|
19
|
-
<tr>
|
|
20
|
-
<td>
|
|
21
|
-
|
|
22
|
-
```sh
|
|
23
|
-
npm install inquirerjs-checkbox-search
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
</td>
|
|
27
|
-
<td>
|
|
28
|
-
|
|
29
|
-
```sh
|
|
30
|
-
yarn add inquirerjs-checkbox-search
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
</td>
|
|
34
|
-
<td>
|
|
35
|
-
|
|
36
|
-
```sh
|
|
37
|
-
pnpm add inquirerjs-checkbox-search
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
</td>
|
|
41
|
-
</tr>
|
|
42
|
-
</table>
|
|
13
|
+
- **npm**: `npm install inquirerjs-checkbox-search`
|
|
14
|
+
- **yarn**: `yarn add inquirerjs-checkbox-search`
|
|
15
|
+
- **pnpm**: `pnpm add inquirerjs-checkbox-search`
|
|
43
16
|
|
|
44
17
|
## Usage
|
|
45
18
|
|
|
@@ -65,19 +38,19 @@ console.log('Selected:', selected);
|
|
|
65
38
|
|
|
66
39
|
### Options
|
|
67
40
|
|
|
68
|
-
| Property | Type | Required | Description
|
|
69
|
-
| -------------- | ----------------------------------------------------------------------------------- | -------- |
|
|
70
|
-
| `message` | `string` | Yes | The question to ask
|
|
71
|
-
| `choices` | `Array<Choice \| string \| Separator>` | No\* | Static list of choices
|
|
72
|
-
| `source` | `(term?: string, opt: { signal: AbortSignal }) => Promise<Array<Choice \| string>>` | No\* | Async function for dynamic choices
|
|
73
|
-
| `pageSize` | `number` | No |
|
|
74
|
-
| `loop` | `boolean` | No | Whether to loop around when navigating (default: true)
|
|
75
|
-
| `required` | `boolean` | No | Require at least one selection (default: false)
|
|
76
|
-
| `validate` | `(selection: Array<Choice>) => boolean \| string \| Promise<string \| boolean>` | No | Custom validation function
|
|
77
|
-
| `instructions` | `string \| boolean` | No | Custom instructions text or false to hide
|
|
78
|
-
| `theme` | `Theme` | No | Custom theme configuration
|
|
79
|
-
| `default` | `Array<Value>` | No | Initially selected values
|
|
80
|
-
| `filter` | `(items: Array<Choice>, term: string) => Array<Choice>` | No | Custom filter function
|
|
41
|
+
| Property | Type | Required | Description |
|
|
42
|
+
| -------------- | ----------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `message` | `string` | Yes | The question to ask |
|
|
44
|
+
| `choices` | `Array<Choice \| string \| Separator>` | No\* | Static list of choices |
|
|
45
|
+
| `source` | `(term?: string, opt: { signal: AbortSignal }) => Promise<Array<Choice \| string>>` | No\* | Async function for dynamic choices |
|
|
46
|
+
| `pageSize` | `number` | No | Fixed number of choices to display. If not specified, auto-sizes based on terminal height (fallback: 7) |
|
|
47
|
+
| `loop` | `boolean` | No | Whether to loop around when navigating (default: true) |
|
|
48
|
+
| `required` | `boolean` | No | Require at least one selection (default: false) |
|
|
49
|
+
| `validate` | `(selection: Array<Choice>) => boolean \| string \| Promise<string \| boolean>` | No | Custom validation function |
|
|
50
|
+
| `instructions` | `string \| boolean` | No | Custom instructions text or false to hide |
|
|
51
|
+
| `theme` | `Theme` | No | Custom theme configuration |
|
|
52
|
+
| `default` | `Array<Value>` | No | Initially selected values |
|
|
53
|
+
| `filter` | `(items: Array<Choice>, term: string) => Array<Choice>` | No | Custom filter function |
|
|
81
54
|
|
|
82
55
|
\*Either `choices` or `source` must be provided.
|
|
83
56
|
|
package/dist/commonjs/index.d.ts
CHANGED
|
@@ -44,6 +44,12 @@ export type NormalizedChoice<Value> = {
|
|
|
44
44
|
disabled: boolean | string;
|
|
45
45
|
checked: boolean;
|
|
46
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Calculate dynamic page size based on terminal height
|
|
49
|
+
* @param fallbackPageSize - Default page size to use if terminal height is not available
|
|
50
|
+
* @returns Calculated page size
|
|
51
|
+
*/
|
|
52
|
+
export declare function calculateDynamicPageSize(fallbackPageSize: number): number;
|
|
47
53
|
/**
|
|
48
54
|
* Main checkbox-search prompt implementation
|
|
49
55
|
*
|
package/dist/commonjs/index.js
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.Separator = void 0;
|
|
7
|
+
exports.calculateDynamicPageSize = calculateDynamicPageSize;
|
|
7
8
|
const core_1 = require("@inquirer/core");
|
|
8
9
|
const yoctocolors_cjs_1 = __importDefault(require("yoctocolors-cjs"));
|
|
9
10
|
const figures_1 = __importDefault(require("@inquirer/figures"));
|
|
@@ -24,7 +25,7 @@ const checkboxSearchTheme = {
|
|
|
24
25
|
help: yoctocolors_cjs_1.default.dim,
|
|
25
26
|
highlight: yoctocolors_cjs_1.default.cyan,
|
|
26
27
|
searchTerm: yoctocolors_cjs_1.default.cyan,
|
|
27
|
-
description: yoctocolors_cjs_1.default.
|
|
28
|
+
description: yoctocolors_cjs_1.default.cyan,
|
|
28
29
|
disabled: yoctocolors_cjs_1.default.dim,
|
|
29
30
|
},
|
|
30
31
|
helpMode: 'always',
|
|
@@ -104,6 +105,40 @@ function defaultFilter(items, term) {
|
|
|
104
105
|
value.includes(searchTerm));
|
|
105
106
|
});
|
|
106
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Calculate dynamic page size based on terminal height
|
|
110
|
+
* @param fallbackPageSize - Default page size to use if terminal height is not available
|
|
111
|
+
* @returns Calculated page size
|
|
112
|
+
*/
|
|
113
|
+
function calculateDynamicPageSize(fallbackPageSize) {
|
|
114
|
+
let rawPageSize;
|
|
115
|
+
try {
|
|
116
|
+
// Get terminal height from process.stdout.rows
|
|
117
|
+
const terminalHeight = process.stdout.rows;
|
|
118
|
+
if (!terminalHeight || terminalHeight < 1) {
|
|
119
|
+
// Fallback to static page size if terminal height is not available
|
|
120
|
+
rawPageSize = fallbackPageSize;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Reserve space for UI elements:
|
|
124
|
+
// - 1 line for the prompt message
|
|
125
|
+
// - 1 line for help instructions
|
|
126
|
+
// - 1 line for search input (if present)
|
|
127
|
+
// - 1 line for error messages (if present)
|
|
128
|
+
// - 1 line for description (if present)
|
|
129
|
+
// - 1 line for buffer/spacing
|
|
130
|
+
const reservedLines = 6;
|
|
131
|
+
// Calculate available lines for choices
|
|
132
|
+
rawPageSize = terminalHeight - reservedLines;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// If there's any error accessing terminal dimensions, fallback gracefully
|
|
137
|
+
rawPageSize = fallbackPageSize;
|
|
138
|
+
}
|
|
139
|
+
// Ensure minimum page size for usability and cap maximum to prevent overwhelming display
|
|
140
|
+
return Math.max(2, Math.min(rawPageSize, 50));
|
|
141
|
+
}
|
|
107
142
|
/**
|
|
108
143
|
* Main checkbox-search prompt implementation
|
|
109
144
|
*
|
|
@@ -124,7 +159,15 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
124
159
|
// Stable reference for empty array to prevent unnecessary recalculations
|
|
125
160
|
const emptyArray = (0, core_1.useMemo)(() => [], []);
|
|
126
161
|
// Configuration with defaults
|
|
127
|
-
const { pageSize
|
|
162
|
+
const { pageSize: configPageSize, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
|
|
163
|
+
// Calculate effective page size (memoized with terminal size tracking)
|
|
164
|
+
// If pageSize is specified, use it as fixed size
|
|
165
|
+
// If not specified, use auto-sizing that recalculates when terminal resizes
|
|
166
|
+
const terminalHeight = process.stdout.rows; // Track terminal size for memoization
|
|
167
|
+
const pageSize = (0, core_1.useMemo)(() => configPageSize !== undefined
|
|
168
|
+
? configPageSize // Fixed page size
|
|
169
|
+
: calculateDynamicPageSize(7), // Auto page size with fallback 7
|
|
170
|
+
[configPageSize, terminalHeight]);
|
|
128
171
|
const theme = (0, core_1.makeTheme)(checkboxSearchTheme, config.theme);
|
|
129
172
|
// State management hooks
|
|
130
173
|
const [status, setStatus] = (0, core_1.useState)('idle');
|
|
@@ -147,6 +190,8 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
147
190
|
}
|
|
148
191
|
return [];
|
|
149
192
|
});
|
|
193
|
+
// Store the active item value instead of active index
|
|
194
|
+
const [activeItemValue, setActiveItemValue] = (0, core_1.useState)(null);
|
|
150
195
|
// Compute filtered items based on search term and filtering logic
|
|
151
196
|
const filteredItems = (0, core_1.useMemo)(() => {
|
|
152
197
|
// Async source mode - use allItems directly (source handles filtering)
|
|
@@ -175,8 +220,48 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
175
220
|
}
|
|
176
221
|
return result;
|
|
177
222
|
}, [allItems, searchTerm, config.source, config.filter]);
|
|
178
|
-
|
|
223
|
+
// Compute active index from activeItemValue
|
|
224
|
+
const active = (0, core_1.useMemo)(() => {
|
|
225
|
+
if (activeItemValue === null) {
|
|
226
|
+
// No active item set, default to first selectable
|
|
227
|
+
const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
|
|
228
|
+
return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
|
|
229
|
+
}
|
|
230
|
+
// Find the item with the active value
|
|
231
|
+
const activeIndex = filteredItems.findIndex((item) => !core_1.Separator.isSeparator(item) &&
|
|
232
|
+
item.value === activeItemValue);
|
|
233
|
+
if (activeIndex !== -1) {
|
|
234
|
+
return activeIndex;
|
|
235
|
+
}
|
|
236
|
+
// Active item not found in filtered list, default to first selectable
|
|
237
|
+
const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
|
|
238
|
+
return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
|
|
239
|
+
}, [filteredItems, activeItemValue]);
|
|
240
|
+
// Update activeItemValue when active index changes (e.g., when filtering results in auto-focus)
|
|
241
|
+
(0, core_1.useEffect)(() => {
|
|
242
|
+
const activeItem = filteredItems[active];
|
|
243
|
+
if (activeItem && !core_1.Separator.isSeparator(activeItem)) {
|
|
244
|
+
const currentActiveValue = activeItem
|
|
245
|
+
.value;
|
|
246
|
+
if (activeItemValue !== currentActiveValue) {
|
|
247
|
+
setActiveItemValue(currentActiveValue);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}, [active, filteredItems, activeItemValue]);
|
|
179
251
|
const [errorMsg, setError] = (0, core_1.useState)();
|
|
252
|
+
// Hide cursor on mount, show on unmount (like other inquirer prompts)
|
|
253
|
+
(0, core_1.useEffect)(() => {
|
|
254
|
+
// Hide cursor when prompt starts (only in TTY environments)
|
|
255
|
+
if (process.stdout.isTTY) {
|
|
256
|
+
process.stdout.write(ansi_escapes_1.default.cursorHide);
|
|
257
|
+
}
|
|
258
|
+
// Show cursor when prompt ends (cleanup function)
|
|
259
|
+
return () => {
|
|
260
|
+
if (process.stdout.isTTY) {
|
|
261
|
+
process.stdout.write(ansi_escapes_1.default.cursorShow);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}, []);
|
|
180
265
|
// Handle async source - load data based on search term
|
|
181
266
|
(0, core_1.useEffect)(() => {
|
|
182
267
|
if (!config.source) {
|
|
@@ -213,31 +298,14 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
213
298
|
(0, core_1.useEffect)(() => {
|
|
214
299
|
allItemsRef.current = allItems;
|
|
215
300
|
}, [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
301
|
// Keyboard event handling
|
|
240
302
|
(0, core_1.useKeypress)((key, rl) => {
|
|
303
|
+
// Helper function to update search term in both readline and React state
|
|
304
|
+
const updateSearchTerm = (newTerm) => {
|
|
305
|
+
rl.clearLine(0);
|
|
306
|
+
rl.write(newTerm);
|
|
307
|
+
setSearchTerm(newTerm);
|
|
308
|
+
};
|
|
241
309
|
// Allow search input even during loading, but block other actions
|
|
242
310
|
const isNavigationOrAction = key.name === 'up' ||
|
|
243
311
|
key.name === 'down' ||
|
|
@@ -251,13 +319,14 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
251
319
|
setError(undefined);
|
|
252
320
|
// Handle Escape key - clear search term quickly
|
|
253
321
|
if (key.name === 'escape') {
|
|
254
|
-
|
|
255
|
-
setSearchTerm(''); // Then update state
|
|
322
|
+
updateSearchTerm(''); // Clear both readline and React state
|
|
256
323
|
return;
|
|
257
324
|
}
|
|
258
|
-
// Handle navigation
|
|
259
|
-
|
|
260
|
-
|
|
325
|
+
// Handle navigation - ONLY actual arrow keys (not vim j/k keys)
|
|
326
|
+
// This follows the official inquirer.js search approach
|
|
327
|
+
if (key.name === 'up' || key.name === 'down') {
|
|
328
|
+
rl.clearLine(0); // Clean readline state before navigation
|
|
329
|
+
const direction = key.name === 'up' ? -1 : 1;
|
|
261
330
|
const selectableIndexes = filteredItems
|
|
262
331
|
.map((item, index) => ({ item, index }))
|
|
263
332
|
.filter(({ item }) => isSelectable(item))
|
|
@@ -275,14 +344,22 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
275
344
|
else {
|
|
276
345
|
nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
|
|
277
346
|
}
|
|
278
|
-
|
|
347
|
+
// Translate from position inside `selectableIndexes` to the real index
|
|
348
|
+
const nextFilteredIndex = selectableIndexes[nextSelectableIndex];
|
|
349
|
+
const nextSelectableItem = filteredItems[nextFilteredIndex];
|
|
350
|
+
if (nextSelectableItem && isSelectable(nextSelectableItem)) {
|
|
351
|
+
setActiveItemValue(nextSelectableItem.value);
|
|
352
|
+
}
|
|
279
353
|
return;
|
|
280
354
|
}
|
|
281
|
-
// Handle selection toggle with tab key
|
|
355
|
+
// Handle selection toggle with tab key
|
|
282
356
|
if (key.name === 'tab') {
|
|
357
|
+
const preservedSearchTerm = searchTerm;
|
|
283
358
|
const activeItem = filteredItems[active];
|
|
284
359
|
if (activeItem && isSelectable(activeItem)) {
|
|
285
360
|
const activeValue = activeItem.value;
|
|
361
|
+
// Set this as the active item value so cursor position is preserved
|
|
362
|
+
setActiveItemValue(activeValue);
|
|
286
363
|
setAllItems(allItems.map((item) => {
|
|
287
364
|
// Compare by value only for robust matching
|
|
288
365
|
if (!core_1.Separator.isSeparator(item) && item.value === activeValue) {
|
|
@@ -292,7 +369,10 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
292
369
|
return item;
|
|
293
370
|
}));
|
|
294
371
|
}
|
|
295
|
-
|
|
372
|
+
updateSearchTerm(preservedSearchTerm);
|
|
373
|
+
// return to prevent tab from affecting search text:
|
|
374
|
+
// Readline's tab completion in @inquirer/core can modify rl.line, adding spaces to the search text
|
|
375
|
+
return;
|
|
296
376
|
}
|
|
297
377
|
// Handle submission
|
|
298
378
|
if ((0, core_1.isEnterKey)(key)) {
|
|
@@ -336,9 +416,18 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
336
416
|
// Handle all other input as search term updates EXCEPT tab
|
|
337
417
|
// Only update search term for actual typing, not navigation keys
|
|
338
418
|
if (!isNavigationOrAction) {
|
|
419
|
+
// For general input, only update React state since rl.line is already current
|
|
339
420
|
setSearchTerm(rl.line);
|
|
340
421
|
}
|
|
341
422
|
});
|
|
423
|
+
// Calculate the active item's description using useMemo for better React patterns
|
|
424
|
+
const activeDescription = (0, core_1.useMemo)(() => {
|
|
425
|
+
const activeItem = filteredItems[active];
|
|
426
|
+
if (activeItem && !core_1.Separator.isSeparator(activeItem)) {
|
|
427
|
+
return activeItem.description;
|
|
428
|
+
}
|
|
429
|
+
return undefined;
|
|
430
|
+
}, [active, filteredItems]);
|
|
342
431
|
// Create renderItem function that's reactive to current state
|
|
343
432
|
const renderItem = (0, core_1.useMemo)(() => {
|
|
344
433
|
return ({ item, isActive }) => {
|
|
@@ -363,32 +452,20 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
363
452
|
let text = item.name;
|
|
364
453
|
if (isActive) {
|
|
365
454
|
text = theme.style.highlight(text);
|
|
455
|
+
// NOTE: Description is now calculated via useMemo, not side-effect mutation
|
|
366
456
|
}
|
|
367
457
|
else if (item.disabled) {
|
|
368
458
|
text = theme.style.disabled(text);
|
|
369
459
|
}
|
|
370
460
|
line.push(text);
|
|
371
|
-
// Show disabled reason if item is disabled
|
|
461
|
+
// Show disabled reason if item is disabled (but no descriptions inline anymore)
|
|
372
462
|
if (item.disabled) {
|
|
373
463
|
const disabledReason = typeof item.disabled === 'string'
|
|
374
464
|
? item.disabled
|
|
375
465
|
: 'disabled';
|
|
376
466
|
line.push(theme.style.disabled(`(${disabledReason})`));
|
|
377
467
|
}
|
|
378
|
-
|
|
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
|
-
}
|
|
468
|
+
// NOTE: Removed the inline description display - descriptions now appear at bottom
|
|
392
469
|
return line.join(' ');
|
|
393
470
|
};
|
|
394
471
|
}, [allItems, theme, config.theme]);
|
|
@@ -403,12 +480,18 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
403
480
|
// Render the prompt
|
|
404
481
|
const message = theme.style.message(config.message, status);
|
|
405
482
|
let helpTip = '';
|
|
406
|
-
if (theme.helpMode === 'always') {
|
|
407
|
-
|
|
408
|
-
|
|
483
|
+
if (theme.helpMode === 'always' && config.instructions !== false) {
|
|
484
|
+
if (typeof config.instructions === 'string') {
|
|
485
|
+
helpTip = `\n${theme.style.help(`(${config.instructions})`)}`;
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
const tips = ['Tab to select', 'Enter to submit'];
|
|
489
|
+
helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
|
|
490
|
+
}
|
|
409
491
|
}
|
|
410
492
|
let searchLine = '';
|
|
411
|
-
|
|
493
|
+
// Always show search line when search functionality is available (static choices or async source)
|
|
494
|
+
if (config.source || config.choices || searchTerm || status === 'loading') {
|
|
412
495
|
const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
|
|
413
496
|
const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
|
|
414
497
|
searchLine = `\n${searchPrefix} ${styledTerm}`;
|
|
@@ -430,7 +513,12 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
430
513
|
else {
|
|
431
514
|
content = `\n${page}`;
|
|
432
515
|
}
|
|
433
|
-
|
|
516
|
+
// Add description of active item at the bottom (like original inquirer.js)
|
|
517
|
+
let descriptionLine = '';
|
|
518
|
+
if (activeDescription) {
|
|
519
|
+
descriptionLine = `\n${theme.style.description(activeDescription)}`;
|
|
520
|
+
}
|
|
521
|
+
return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${descriptionLine}`;
|
|
434
522
|
});
|
|
435
523
|
// Re-export Separator for convenience
|
|
436
524
|
var core_2 = require("@inquirer/core");
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -44,6 +44,12 @@ export type NormalizedChoice<Value> = {
|
|
|
44
44
|
disabled: boolean | string;
|
|
45
45
|
checked: boolean;
|
|
46
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Calculate dynamic page size based on terminal height
|
|
49
|
+
* @param fallbackPageSize - Default page size to use if terminal height is not available
|
|
50
|
+
* @returns Calculated page size
|
|
51
|
+
*/
|
|
52
|
+
export declare function calculateDynamicPageSize(fallbackPageSize: number): number;
|
|
47
53
|
/**
|
|
48
54
|
* Main checkbox-search prompt implementation
|
|
49
55
|
*
|
package/dist/esm/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPrompt, useState, useKeypress, usePagination, useEffect, useRef, useMemo, usePrefix, makeTheme,
|
|
1
|
+
import { createPrompt, useState, useKeypress, usePagination, useEffect, useRef, useMemo, usePrefix, makeTheme, isEnterKey, Separator, } from '@inquirer/core';
|
|
2
2
|
import colors from 'yoctocolors-cjs';
|
|
3
3
|
import figures from '@inquirer/figures';
|
|
4
4
|
import ansiEscapes from 'ansi-escapes';
|
|
@@ -18,7 +18,7 @@ const checkboxSearchTheme = {
|
|
|
18
18
|
help: colors.dim,
|
|
19
19
|
highlight: colors.cyan,
|
|
20
20
|
searchTerm: colors.cyan,
|
|
21
|
-
description: colors.
|
|
21
|
+
description: colors.cyan,
|
|
22
22
|
disabled: colors.dim,
|
|
23
23
|
},
|
|
24
24
|
helpMode: 'always',
|
|
@@ -98,6 +98,40 @@ function defaultFilter(items, term) {
|
|
|
98
98
|
value.includes(searchTerm));
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Calculate dynamic page size based on terminal height
|
|
103
|
+
* @param fallbackPageSize - Default page size to use if terminal height is not available
|
|
104
|
+
* @returns Calculated page size
|
|
105
|
+
*/
|
|
106
|
+
export function calculateDynamicPageSize(fallbackPageSize) {
|
|
107
|
+
let rawPageSize;
|
|
108
|
+
try {
|
|
109
|
+
// Get terminal height from process.stdout.rows
|
|
110
|
+
const terminalHeight = process.stdout.rows;
|
|
111
|
+
if (!terminalHeight || terminalHeight < 1) {
|
|
112
|
+
// Fallback to static page size if terminal height is not available
|
|
113
|
+
rawPageSize = fallbackPageSize;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// Reserve space for UI elements:
|
|
117
|
+
// - 1 line for the prompt message
|
|
118
|
+
// - 1 line for help instructions
|
|
119
|
+
// - 1 line for search input (if present)
|
|
120
|
+
// - 1 line for error messages (if present)
|
|
121
|
+
// - 1 line for description (if present)
|
|
122
|
+
// - 1 line for buffer/spacing
|
|
123
|
+
const reservedLines = 6;
|
|
124
|
+
// Calculate available lines for choices
|
|
125
|
+
rawPageSize = terminalHeight - reservedLines;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// If there's any error accessing terminal dimensions, fallback gracefully
|
|
130
|
+
rawPageSize = fallbackPageSize;
|
|
131
|
+
}
|
|
132
|
+
// Ensure minimum page size for usability and cap maximum to prevent overwhelming display
|
|
133
|
+
return Math.max(2, Math.min(rawPageSize, 50));
|
|
134
|
+
}
|
|
101
135
|
/**
|
|
102
136
|
* Main checkbox-search prompt implementation
|
|
103
137
|
*
|
|
@@ -118,7 +152,15 @@ export default createPrompt((config, done) => {
|
|
|
118
152
|
// Stable reference for empty array to prevent unnecessary recalculations
|
|
119
153
|
const emptyArray = useMemo(() => [], []);
|
|
120
154
|
// Configuration with defaults
|
|
121
|
-
const { pageSize
|
|
155
|
+
const { pageSize: configPageSize, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
|
|
156
|
+
// Calculate effective page size (memoized with terminal size tracking)
|
|
157
|
+
// If pageSize is specified, use it as fixed size
|
|
158
|
+
// If not specified, use auto-sizing that recalculates when terminal resizes
|
|
159
|
+
const terminalHeight = process.stdout.rows; // Track terminal size for memoization
|
|
160
|
+
const pageSize = useMemo(() => configPageSize !== undefined
|
|
161
|
+
? configPageSize // Fixed page size
|
|
162
|
+
: calculateDynamicPageSize(7), // Auto page size with fallback 7
|
|
163
|
+
[configPageSize, terminalHeight]);
|
|
122
164
|
const theme = makeTheme(checkboxSearchTheme, config.theme);
|
|
123
165
|
// State management hooks
|
|
124
166
|
const [status, setStatus] = useState('idle');
|
|
@@ -141,6 +183,8 @@ export default createPrompt((config, done) => {
|
|
|
141
183
|
}
|
|
142
184
|
return [];
|
|
143
185
|
});
|
|
186
|
+
// Store the active item value instead of active index
|
|
187
|
+
const [activeItemValue, setActiveItemValue] = useState(null);
|
|
144
188
|
// Compute filtered items based on search term and filtering logic
|
|
145
189
|
const filteredItems = useMemo(() => {
|
|
146
190
|
// Async source mode - use allItems directly (source handles filtering)
|
|
@@ -169,8 +213,48 @@ export default createPrompt((config, done) => {
|
|
|
169
213
|
}
|
|
170
214
|
return result;
|
|
171
215
|
}, [allItems, searchTerm, config.source, config.filter]);
|
|
172
|
-
|
|
216
|
+
// Compute active index from activeItemValue
|
|
217
|
+
const active = useMemo(() => {
|
|
218
|
+
if (activeItemValue === null) {
|
|
219
|
+
// No active item set, default to first selectable
|
|
220
|
+
const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
|
|
221
|
+
return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
|
|
222
|
+
}
|
|
223
|
+
// Find the item with the active value
|
|
224
|
+
const activeIndex = filteredItems.findIndex((item) => !Separator.isSeparator(item) &&
|
|
225
|
+
item.value === activeItemValue);
|
|
226
|
+
if (activeIndex !== -1) {
|
|
227
|
+
return activeIndex;
|
|
228
|
+
}
|
|
229
|
+
// Active item not found in filtered list, default to first selectable
|
|
230
|
+
const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
|
|
231
|
+
return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
|
|
232
|
+
}, [filteredItems, activeItemValue]);
|
|
233
|
+
// Update activeItemValue when active index changes (e.g., when filtering results in auto-focus)
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const activeItem = filteredItems[active];
|
|
236
|
+
if (activeItem && !Separator.isSeparator(activeItem)) {
|
|
237
|
+
const currentActiveValue = activeItem
|
|
238
|
+
.value;
|
|
239
|
+
if (activeItemValue !== currentActiveValue) {
|
|
240
|
+
setActiveItemValue(currentActiveValue);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}, [active, filteredItems, activeItemValue]);
|
|
173
244
|
const [errorMsg, setError] = useState();
|
|
245
|
+
// Hide cursor on mount, show on unmount (like other inquirer prompts)
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
// Hide cursor when prompt starts (only in TTY environments)
|
|
248
|
+
if (process.stdout.isTTY) {
|
|
249
|
+
process.stdout.write(ansiEscapes.cursorHide);
|
|
250
|
+
}
|
|
251
|
+
// Show cursor when prompt ends (cleanup function)
|
|
252
|
+
return () => {
|
|
253
|
+
if (process.stdout.isTTY) {
|
|
254
|
+
process.stdout.write(ansiEscapes.cursorShow);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}, []);
|
|
174
258
|
// Handle async source - load data based on search term
|
|
175
259
|
useEffect(() => {
|
|
176
260
|
if (!config.source) {
|
|
@@ -207,31 +291,14 @@ export default createPrompt((config, done) => {
|
|
|
207
291
|
useEffect(() => {
|
|
208
292
|
allItemsRef.current = allItems;
|
|
209
293
|
}, [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
294
|
// Keyboard event handling
|
|
234
295
|
useKeypress((key, rl) => {
|
|
296
|
+
// Helper function to update search term in both readline and React state
|
|
297
|
+
const updateSearchTerm = (newTerm) => {
|
|
298
|
+
rl.clearLine(0);
|
|
299
|
+
rl.write(newTerm);
|
|
300
|
+
setSearchTerm(newTerm);
|
|
301
|
+
};
|
|
235
302
|
// Allow search input even during loading, but block other actions
|
|
236
303
|
const isNavigationOrAction = key.name === 'up' ||
|
|
237
304
|
key.name === 'down' ||
|
|
@@ -245,13 +312,14 @@ export default createPrompt((config, done) => {
|
|
|
245
312
|
setError(undefined);
|
|
246
313
|
// Handle Escape key - clear search term quickly
|
|
247
314
|
if (key.name === 'escape') {
|
|
248
|
-
|
|
249
|
-
setSearchTerm(''); // Then update state
|
|
315
|
+
updateSearchTerm(''); // Clear both readline and React state
|
|
250
316
|
return;
|
|
251
317
|
}
|
|
252
|
-
// Handle navigation
|
|
253
|
-
|
|
254
|
-
|
|
318
|
+
// Handle navigation - ONLY actual arrow keys (not vim j/k keys)
|
|
319
|
+
// This follows the official inquirer.js search approach
|
|
320
|
+
if (key.name === 'up' || key.name === 'down') {
|
|
321
|
+
rl.clearLine(0); // Clean readline state before navigation
|
|
322
|
+
const direction = key.name === 'up' ? -1 : 1;
|
|
255
323
|
const selectableIndexes = filteredItems
|
|
256
324
|
.map((item, index) => ({ item, index }))
|
|
257
325
|
.filter(({ item }) => isSelectable(item))
|
|
@@ -269,14 +337,22 @@ export default createPrompt((config, done) => {
|
|
|
269
337
|
else {
|
|
270
338
|
nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
|
|
271
339
|
}
|
|
272
|
-
|
|
340
|
+
// Translate from position inside `selectableIndexes` to the real index
|
|
341
|
+
const nextFilteredIndex = selectableIndexes[nextSelectableIndex];
|
|
342
|
+
const nextSelectableItem = filteredItems[nextFilteredIndex];
|
|
343
|
+
if (nextSelectableItem && isSelectable(nextSelectableItem)) {
|
|
344
|
+
setActiveItemValue(nextSelectableItem.value);
|
|
345
|
+
}
|
|
273
346
|
return;
|
|
274
347
|
}
|
|
275
|
-
// Handle selection toggle with tab key
|
|
348
|
+
// Handle selection toggle with tab key
|
|
276
349
|
if (key.name === 'tab') {
|
|
350
|
+
const preservedSearchTerm = searchTerm;
|
|
277
351
|
const activeItem = filteredItems[active];
|
|
278
352
|
if (activeItem && isSelectable(activeItem)) {
|
|
279
353
|
const activeValue = activeItem.value;
|
|
354
|
+
// Set this as the active item value so cursor position is preserved
|
|
355
|
+
setActiveItemValue(activeValue);
|
|
280
356
|
setAllItems(allItems.map((item) => {
|
|
281
357
|
// Compare by value only for robust matching
|
|
282
358
|
if (!Separator.isSeparator(item) && item.value === activeValue) {
|
|
@@ -286,7 +362,10 @@ export default createPrompt((config, done) => {
|
|
|
286
362
|
return item;
|
|
287
363
|
}));
|
|
288
364
|
}
|
|
289
|
-
|
|
365
|
+
updateSearchTerm(preservedSearchTerm);
|
|
366
|
+
// return to prevent tab from affecting search text:
|
|
367
|
+
// Readline's tab completion in @inquirer/core can modify rl.line, adding spaces to the search text
|
|
368
|
+
return;
|
|
290
369
|
}
|
|
291
370
|
// Handle submission
|
|
292
371
|
if (isEnterKey(key)) {
|
|
@@ -330,9 +409,18 @@ export default createPrompt((config, done) => {
|
|
|
330
409
|
// Handle all other input as search term updates EXCEPT tab
|
|
331
410
|
// Only update search term for actual typing, not navigation keys
|
|
332
411
|
if (!isNavigationOrAction) {
|
|
412
|
+
// For general input, only update React state since rl.line is already current
|
|
333
413
|
setSearchTerm(rl.line);
|
|
334
414
|
}
|
|
335
415
|
});
|
|
416
|
+
// Calculate the active item's description using useMemo for better React patterns
|
|
417
|
+
const activeDescription = useMemo(() => {
|
|
418
|
+
const activeItem = filteredItems[active];
|
|
419
|
+
if (activeItem && !Separator.isSeparator(activeItem)) {
|
|
420
|
+
return activeItem.description;
|
|
421
|
+
}
|
|
422
|
+
return undefined;
|
|
423
|
+
}, [active, filteredItems]);
|
|
336
424
|
// Create renderItem function that's reactive to current state
|
|
337
425
|
const renderItem = useMemo(() => {
|
|
338
426
|
return ({ item, isActive }) => {
|
|
@@ -357,32 +445,20 @@ export default createPrompt((config, done) => {
|
|
|
357
445
|
let text = item.name;
|
|
358
446
|
if (isActive) {
|
|
359
447
|
text = theme.style.highlight(text);
|
|
448
|
+
// NOTE: Description is now calculated via useMemo, not side-effect mutation
|
|
360
449
|
}
|
|
361
450
|
else if (item.disabled) {
|
|
362
451
|
text = theme.style.disabled(text);
|
|
363
452
|
}
|
|
364
453
|
line.push(text);
|
|
365
|
-
// Show disabled reason if item is disabled
|
|
454
|
+
// Show disabled reason if item is disabled (but no descriptions inline anymore)
|
|
366
455
|
if (item.disabled) {
|
|
367
456
|
const disabledReason = typeof item.disabled === 'string'
|
|
368
457
|
? item.disabled
|
|
369
458
|
: 'disabled';
|
|
370
459
|
line.push(theme.style.disabled(`(${disabledReason})`));
|
|
371
460
|
}
|
|
372
|
-
|
|
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
|
-
}
|
|
461
|
+
// NOTE: Removed the inline description display - descriptions now appear at bottom
|
|
386
462
|
return line.join(' ');
|
|
387
463
|
};
|
|
388
464
|
}, [allItems, theme, config.theme]);
|
|
@@ -397,12 +473,18 @@ export default createPrompt((config, done) => {
|
|
|
397
473
|
// Render the prompt
|
|
398
474
|
const message = theme.style.message(config.message, status);
|
|
399
475
|
let helpTip = '';
|
|
400
|
-
if (theme.helpMode === 'always') {
|
|
401
|
-
|
|
402
|
-
|
|
476
|
+
if (theme.helpMode === 'always' && config.instructions !== false) {
|
|
477
|
+
if (typeof config.instructions === 'string') {
|
|
478
|
+
helpTip = `\n${theme.style.help(`(${config.instructions})`)}`;
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
const tips = ['Tab to select', 'Enter to submit'];
|
|
482
|
+
helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
|
|
483
|
+
}
|
|
403
484
|
}
|
|
404
485
|
let searchLine = '';
|
|
405
|
-
|
|
486
|
+
// Always show search line when search functionality is available (static choices or async source)
|
|
487
|
+
if (config.source || config.choices || searchTerm || status === 'loading') {
|
|
406
488
|
const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
|
|
407
489
|
const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
|
|
408
490
|
searchLine = `\n${searchPrefix} ${styledTerm}`;
|
|
@@ -424,7 +506,12 @@ export default createPrompt((config, done) => {
|
|
|
424
506
|
else {
|
|
425
507
|
content = `\n${page}`;
|
|
426
508
|
}
|
|
427
|
-
|
|
509
|
+
// Add description of active item at the bottom (like original inquirer.js)
|
|
510
|
+
let descriptionLine = '';
|
|
511
|
+
if (activeDescription) {
|
|
512
|
+
descriptionLine = `\n${theme.style.description(activeDescription)}`;
|
|
513
|
+
}
|
|
514
|
+
return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${descriptionLine}`;
|
|
428
515
|
});
|
|
429
516
|
// Re-export Separator for convenience
|
|
430
517
|
export { Separator } from '@inquirer/core';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inquirerjs-checkbox-search",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "A multi-select prompt with text filtering for inquirer.js",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"answer",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
],
|
|
65
65
|
"scripts": {
|
|
66
66
|
"build": "tshy",
|
|
67
|
-
"clean": "rimraf dist .tshy",
|
|
67
|
+
"clean": "rimraf dist .tshy .tshy-build package-inspect coverage src/node_modules",
|
|
68
68
|
"dev": "tshy --watch",
|
|
69
69
|
"lint": "eslint . --ext .ts --fix",
|
|
70
70
|
"lint:check": "eslint . --ext .ts",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@inquirer/core": "^10.1.13",
|
|
85
85
|
"@inquirer/figures": "^1.0.12",
|
|
86
86
|
"@inquirer/type": "^3.0.7",
|
|
87
|
-
"ansi-escapes": "^
|
|
87
|
+
"ansi-escapes": "^7.0.0",
|
|
88
88
|
"yoctocolors-cjs": "^2.1.2"
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
@@ -93,15 +93,15 @@
|
|
|
93
93
|
"@types/node": "^20.0.0",
|
|
94
94
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
95
95
|
"@typescript-eslint/parser": "^8.0.0",
|
|
96
|
-
"@vitest/coverage-v8": "^2.
|
|
97
|
-
"@vitest/ui": "^2.
|
|
96
|
+
"@vitest/coverage-v8": "^3.2.3",
|
|
97
|
+
"@vitest/ui": "^3.2.3",
|
|
98
98
|
"eslint": "^9.0.0",
|
|
99
99
|
"eslint-config-prettier": "^9.0.0",
|
|
100
100
|
"prettier": "^3.0.0",
|
|
101
101
|
"rimraf": "^6.0.0",
|
|
102
102
|
"tshy": "^3.0.2",
|
|
103
103
|
"typescript": "^5.6.0",
|
|
104
|
-
"vitest": "^2.
|
|
104
|
+
"vitest": "^3.2.3"
|
|
105
105
|
},
|
|
106
106
|
"engines": {
|
|
107
107
|
"node": ">=18"
|