inquirerjs-checkbox-search 0.1.2 → 0.3.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.
- package/README.md +44 -58
- package/dist/commonjs/index.d.ts +44 -1
- package/dist/commonjs/index.js +251 -54
- package/dist/esm/index.d.ts +44 -1
- package/dist/esm/index.js +248 -55
- package/package.json +10 -8
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`
|
|
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 \| PageSizeConfig` | No | Page size configuration. Can be a number (fixed size) or PageSizeConfig object for advanced control. 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
|
|
|
@@ -94,6 +67,33 @@ type Choice<Value = any> = {
|
|
|
94
67
|
};
|
|
95
68
|
```
|
|
96
69
|
|
|
70
|
+
### PageSize Configuration
|
|
71
|
+
|
|
72
|
+
The `pageSize` property accepts either a number (for simple fixed sizing) or a `PageSizeConfig` object for advanced control:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
type PageSizeConfig = {
|
|
76
|
+
base?: number; // Starting page size (if not specified, auto-calculated from terminal)
|
|
77
|
+
max?: number; // Maximum page size (absolute constraint)
|
|
78
|
+
min?: number; // Minimum page size (absolute constraint, defaults to 1)
|
|
79
|
+
buffer?: number; // Fixed buffer lines to subtract from page size
|
|
80
|
+
autoBufferDescriptions?: boolean; // Auto-reserve space for descriptions
|
|
81
|
+
autoBufferCountsLineWidth?: boolean; // Consider terminal width when counting description lines
|
|
82
|
+
minBuffer?: number; // Minimum buffer lines (applied after auto/manual buffer)
|
|
83
|
+
};
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Buffer Calculation Process:**
|
|
87
|
+
|
|
88
|
+
1. Start with base page size (from `base` or auto-calculated)
|
|
89
|
+
2. Calculate buffer:
|
|
90
|
+
- If `autoBufferDescriptions` is true: Add lines needed for largest description
|
|
91
|
+
- Otherwise: Add `buffer` value (if specified)
|
|
92
|
+
- Ensure buffer is at least `minBuffer` (if specified)
|
|
93
|
+
3. Subtract buffer from base page size
|
|
94
|
+
4. Apply `min`/`max` constraints
|
|
95
|
+
5. Ensure final result is at least 1
|
|
96
|
+
|
|
97
97
|
### Theme Options
|
|
98
98
|
|
|
99
99
|
```typescript
|
|
@@ -129,21 +129,7 @@ type CheckboxSearchTheme = {
|
|
|
129
129
|
|
|
130
130
|
## Advanced Features
|
|
131
131
|
|
|
132
|
-
For detailed examples of advanced features, see the [`examples/`](./examples/) directory
|
|
133
|
-
|
|
134
|
-
- **[Basic Multi-Select](./examples/basic.js)** - Simple multi-select functionality
|
|
135
|
-
- **[Search Filtering](./examples/search-filtering.js)** - Real-time search with larger lists
|
|
136
|
-
- **[Async Source](./examples/async-source.js)** - Dynamic loading with mock API
|
|
137
|
-
- **[Custom Theme](./examples/custom-theme.js)** - Custom icons and styling
|
|
138
|
-
- **[Validation](./examples/validation.js)** - Input validation and pre-selection
|
|
139
|
-
- **[Custom Filter](./examples/custom-filter.js)** - Fuzzy matching filter
|
|
140
|
-
|
|
141
|
-
**To run examples:**
|
|
142
|
-
|
|
143
|
-
1. Build the package: `npm run build`
|
|
144
|
-
2. Run any example: `node examples/basic.js`
|
|
145
|
-
|
|
146
|
-
See the [examples README](./examples/README.md) for detailed instructions.
|
|
132
|
+
For detailed examples of advanced features, see the [`examples/`](./examples/) directory.
|
|
147
133
|
|
|
148
134
|
Each example includes detailed comments and demonstrates real-world usage patterns.
|
|
149
135
|
|
package/dist/commonjs/index.d.ts
CHANGED
|
@@ -44,6 +44,49 @@ export type NormalizedChoice<Value> = {
|
|
|
44
44
|
disabled: boolean | string;
|
|
45
45
|
checked: boolean;
|
|
46
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Configuration options for the checkbox-search prompt
|
|
49
|
+
*/
|
|
50
|
+
export type PageSizeConfig = {
|
|
51
|
+
base?: number;
|
|
52
|
+
max?: number;
|
|
53
|
+
min?: number;
|
|
54
|
+
autoBufferDescriptions?: boolean;
|
|
55
|
+
buffer?: number;
|
|
56
|
+
minBuffer?: number;
|
|
57
|
+
autoBufferCountsLineWidth?: boolean;
|
|
58
|
+
};
|
|
59
|
+
export type PageSize = number | PageSizeConfig;
|
|
60
|
+
/**
|
|
61
|
+
* Internal item type (choice or separator)
|
|
62
|
+
*/
|
|
63
|
+
type Item<Value> = NormalizedChoice<Value> | Separator;
|
|
64
|
+
/**
|
|
65
|
+
* Validate PageSizeConfig object for correctness
|
|
66
|
+
* @param config - PageSizeConfig to validate
|
|
67
|
+
* @throws Error if validation fails
|
|
68
|
+
*/
|
|
69
|
+
export declare function validatePageSizeConfig(config: PageSizeConfig): void;
|
|
70
|
+
/**
|
|
71
|
+
* Calculate the maximum number of lines needed for descriptions across all items
|
|
72
|
+
* @param items - Array of items to analyze
|
|
73
|
+
* @param countLineWidth - Whether to consider terminal width for line wrapping
|
|
74
|
+
* @returns Maximum lines needed by any description
|
|
75
|
+
*/
|
|
76
|
+
export declare function calculateDescriptionLines<Value>(items: readonly Item<Value>[], countLineWidth: boolean): number;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve PageSize configuration to a final numeric page size
|
|
79
|
+
* @param pageSize - PageSize configuration (number or PageSizeConfig)
|
|
80
|
+
* @param items - Current items to consider for auto-buffering
|
|
81
|
+
* @returns Final resolved page size
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolvePageSize<Value>(pageSize: PageSize, items: readonly Item<Value>[]): number;
|
|
84
|
+
/**
|
|
85
|
+
* Calculate dynamic page size based on terminal height
|
|
86
|
+
* @param fallbackPageSize - Default page size to use if terminal height is not available
|
|
87
|
+
* @returns Calculated page size
|
|
88
|
+
*/
|
|
89
|
+
export declare function calculateDynamicPageSize(fallbackPageSize: number): number;
|
|
47
90
|
/**
|
|
48
91
|
* Main checkbox-search prompt implementation
|
|
49
92
|
*
|
|
@@ -63,7 +106,7 @@ export type NormalizedChoice<Value> = {
|
|
|
63
106
|
declare const _default: <Value>(config: {
|
|
64
107
|
message: string;
|
|
65
108
|
prefix?: string | undefined;
|
|
66
|
-
pageSize?:
|
|
109
|
+
pageSize?: PageSize | undefined;
|
|
67
110
|
instructions?: string | boolean | undefined;
|
|
68
111
|
choices?: readonly (string | Separator)[] | readonly (Separator | Choice<Value>)[] | undefined;
|
|
69
112
|
source?: ((term: string | undefined, opt: {
|
package/dist/commonjs/index.js
CHANGED
|
@@ -4,6 +4,10 @@ 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.validatePageSizeConfig = validatePageSizeConfig;
|
|
8
|
+
exports.calculateDescriptionLines = calculateDescriptionLines;
|
|
9
|
+
exports.resolvePageSize = resolvePageSize;
|
|
10
|
+
exports.calculateDynamicPageSize = calculateDynamicPageSize;
|
|
7
11
|
const core_1 = require("@inquirer/core");
|
|
8
12
|
const yoctocolors_cjs_1 = __importDefault(require("yoctocolors-cjs"));
|
|
9
13
|
const figures_1 = __importDefault(require("@inquirer/figures"));
|
|
@@ -24,7 +28,7 @@ const checkboxSearchTheme = {
|
|
|
24
28
|
help: yoctocolors_cjs_1.default.dim,
|
|
25
29
|
highlight: yoctocolors_cjs_1.default.cyan,
|
|
26
30
|
searchTerm: yoctocolors_cjs_1.default.cyan,
|
|
27
|
-
description: yoctocolors_cjs_1.default.
|
|
31
|
+
description: yoctocolors_cjs_1.default.cyan,
|
|
28
32
|
disabled: yoctocolors_cjs_1.default.dim,
|
|
29
33
|
},
|
|
30
34
|
helpMode: 'always',
|
|
@@ -104,6 +108,142 @@ function defaultFilter(items, term) {
|
|
|
104
108
|
value.includes(searchTerm));
|
|
105
109
|
});
|
|
106
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Validate PageSizeConfig object for correctness
|
|
113
|
+
* @param config - PageSizeConfig to validate
|
|
114
|
+
* @throws Error if validation fails
|
|
115
|
+
*/
|
|
116
|
+
function validatePageSizeConfig(config) {
|
|
117
|
+
if (config.min !== undefined && config.min < 1) {
|
|
118
|
+
throw new Error('PageSize min cannot be less than 1');
|
|
119
|
+
}
|
|
120
|
+
if (config.base !== undefined && config.base < 1) {
|
|
121
|
+
throw new Error('PageSize base cannot be less than 1');
|
|
122
|
+
}
|
|
123
|
+
if (config.buffer !== undefined && config.buffer < 0) {
|
|
124
|
+
throw new Error('PageSize buffer cannot be negative');
|
|
125
|
+
}
|
|
126
|
+
if (config.minBuffer !== undefined && config.minBuffer < 0) {
|
|
127
|
+
throw new Error('PageSize minBuffer cannot be negative');
|
|
128
|
+
}
|
|
129
|
+
if (config.min !== undefined &&
|
|
130
|
+
config.max !== undefined &&
|
|
131
|
+
config.min > config.max) {
|
|
132
|
+
throw new Error(`PageSize min (${config.min}) cannot be greater than max (${config.max})`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Calculate the maximum number of lines needed for descriptions across all items
|
|
137
|
+
* @param items - Array of items to analyze
|
|
138
|
+
* @param countLineWidth - Whether to consider terminal width for line wrapping
|
|
139
|
+
* @returns Maximum lines needed by any description
|
|
140
|
+
*/
|
|
141
|
+
function calculateDescriptionLines(items, countLineWidth) {
|
|
142
|
+
let maxLines = 0;
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
if (core_1.Separator.isSeparator(item) || !item.description) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let lines;
|
|
148
|
+
if (countLineWidth) {
|
|
149
|
+
// Consider terminal width for wrapping
|
|
150
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
151
|
+
const descriptionLines = item.description.split('\n');
|
|
152
|
+
lines = descriptionLines.reduce((total, line) => {
|
|
153
|
+
return total + (Math.ceil(line.length / terminalWidth) || 1);
|
|
154
|
+
}, 0);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Simple newline counting
|
|
158
|
+
lines = item.description.split('\n').length;
|
|
159
|
+
}
|
|
160
|
+
maxLines = Math.max(maxLines, lines);
|
|
161
|
+
}
|
|
162
|
+
return maxLines;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Resolve PageSize configuration to a final numeric page size
|
|
166
|
+
* @param pageSize - PageSize configuration (number or PageSizeConfig)
|
|
167
|
+
* @param items - Current items to consider for auto-buffering
|
|
168
|
+
* @returns Final resolved page size
|
|
169
|
+
*/
|
|
170
|
+
function resolvePageSize(pageSize, items) {
|
|
171
|
+
// Handle simple number case (backward compatibility)
|
|
172
|
+
if (typeof pageSize === 'number') {
|
|
173
|
+
return pageSize;
|
|
174
|
+
}
|
|
175
|
+
// Validate the configuration
|
|
176
|
+
validatePageSizeConfig(pageSize);
|
|
177
|
+
// Step 1: Determine base page size
|
|
178
|
+
let basePageSize;
|
|
179
|
+
if (pageSize.base !== undefined) {
|
|
180
|
+
basePageSize = pageSize.base;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Auto-calculate using existing logic
|
|
184
|
+
basePageSize = calculateDynamicPageSize(7);
|
|
185
|
+
}
|
|
186
|
+
// Step 2: Calculate buffer reduction
|
|
187
|
+
let buffer = 0;
|
|
188
|
+
// 2a: Start at 0 ✓
|
|
189
|
+
// 2b: If autoBufferDescriptions, add max description lines
|
|
190
|
+
if (pageSize.autoBufferDescriptions) {
|
|
191
|
+
buffer += calculateDescriptionLines(items, pageSize.autoBufferCountsLineWidth || false);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// 2c: Add buffer value (only if not auto-buffering)
|
|
195
|
+
buffer += pageSize.buffer || 0;
|
|
196
|
+
}
|
|
197
|
+
// 2d: Ensure at least minBuffer
|
|
198
|
+
if (pageSize.minBuffer !== undefined) {
|
|
199
|
+
buffer = Math.max(buffer, pageSize.minBuffer);
|
|
200
|
+
}
|
|
201
|
+
// Step 3: Subtract buffer from base
|
|
202
|
+
let finalPageSize = basePageSize - buffer;
|
|
203
|
+
// Step 4: Apply min/max constraints
|
|
204
|
+
if (pageSize.min !== undefined) {
|
|
205
|
+
finalPageSize = Math.max(finalPageSize, pageSize.min);
|
|
206
|
+
}
|
|
207
|
+
if (pageSize.max !== undefined) {
|
|
208
|
+
finalPageSize = Math.min(finalPageSize, pageSize.max);
|
|
209
|
+
}
|
|
210
|
+
// Ensure minimum of 1
|
|
211
|
+
return Math.max(1, finalPageSize);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Calculate dynamic page size based on terminal height
|
|
215
|
+
* @param fallbackPageSize - Default page size to use if terminal height is not available
|
|
216
|
+
* @returns Calculated page size
|
|
217
|
+
*/
|
|
218
|
+
function calculateDynamicPageSize(fallbackPageSize) {
|
|
219
|
+
let rawPageSize;
|
|
220
|
+
try {
|
|
221
|
+
// Get terminal height from process.stdout.rows
|
|
222
|
+
const terminalHeight = process.stdout.rows;
|
|
223
|
+
if (!terminalHeight || terminalHeight < 1) {
|
|
224
|
+
// Fallback to static page size if terminal height is not available
|
|
225
|
+
rawPageSize = fallbackPageSize;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// Reserve space for UI elements:
|
|
229
|
+
// - 1 line for the prompt message
|
|
230
|
+
// - 1 line for help instructions
|
|
231
|
+
// - 1 line for search input (if present)
|
|
232
|
+
// - 1 line for error messages (if present)
|
|
233
|
+
// - 1 line for description (if present)
|
|
234
|
+
// - 1 line for buffer/spacing
|
|
235
|
+
const reservedLines = 6;
|
|
236
|
+
// Calculate available lines for choices
|
|
237
|
+
rawPageSize = terminalHeight - reservedLines;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// If there's any error accessing terminal dimensions, fallback gracefully
|
|
242
|
+
rawPageSize = fallbackPageSize;
|
|
243
|
+
}
|
|
244
|
+
// Ensure minimum page size for usability and cap maximum to prevent overwhelming display
|
|
245
|
+
return Math.max(2, Math.min(rawPageSize, 50));
|
|
246
|
+
}
|
|
107
247
|
/**
|
|
108
248
|
* Main checkbox-search prompt implementation
|
|
109
249
|
*
|
|
@@ -124,7 +264,7 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
124
264
|
// Stable reference for empty array to prevent unnecessary recalculations
|
|
125
265
|
const emptyArray = (0, core_1.useMemo)(() => [], []);
|
|
126
266
|
// Configuration with defaults
|
|
127
|
-
const { pageSize
|
|
267
|
+
const { pageSize: configPageSize, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
|
|
128
268
|
const theme = (0, core_1.makeTheme)(checkboxSearchTheme, config.theme);
|
|
129
269
|
// State management hooks
|
|
130
270
|
const [status, setStatus] = (0, core_1.useState)('idle');
|
|
@@ -147,6 +287,20 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
147
287
|
}
|
|
148
288
|
return [];
|
|
149
289
|
});
|
|
290
|
+
// Calculate effective page size (memoized with terminal size tracking)
|
|
291
|
+
// Use new resolvePageSize function to handle both number and PageSizeConfig
|
|
292
|
+
const terminalHeight = process.stdout.rows; // Track terminal size for memoization
|
|
293
|
+
const pageSize = (0, core_1.useMemo)(() => {
|
|
294
|
+
if (configPageSize !== undefined) {
|
|
295
|
+
return resolvePageSize(configPageSize, allItems);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// Default behavior - auto-calculate
|
|
299
|
+
return calculateDynamicPageSize(7);
|
|
300
|
+
}
|
|
301
|
+
}, [configPageSize, terminalHeight, allItems]);
|
|
302
|
+
// Store the active item value instead of active index
|
|
303
|
+
const [activeItemValue, setActiveItemValue] = (0, core_1.useState)(null);
|
|
150
304
|
// Compute filtered items based on search term and filtering logic
|
|
151
305
|
const filteredItems = (0, core_1.useMemo)(() => {
|
|
152
306
|
// Async source mode - use allItems directly (source handles filtering)
|
|
@@ -175,8 +329,48 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
175
329
|
}
|
|
176
330
|
return result;
|
|
177
331
|
}, [allItems, searchTerm, config.source, config.filter]);
|
|
178
|
-
|
|
332
|
+
// Compute active index from activeItemValue
|
|
333
|
+
const active = (0, core_1.useMemo)(() => {
|
|
334
|
+
if (activeItemValue === null) {
|
|
335
|
+
// No active item set, default to first selectable
|
|
336
|
+
const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
|
|
337
|
+
return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
|
|
338
|
+
}
|
|
339
|
+
// Find the item with the active value
|
|
340
|
+
const activeIndex = filteredItems.findIndex((item) => !core_1.Separator.isSeparator(item) &&
|
|
341
|
+
item.value === activeItemValue);
|
|
342
|
+
if (activeIndex !== -1) {
|
|
343
|
+
return activeIndex;
|
|
344
|
+
}
|
|
345
|
+
// Active item not found in filtered list, default to first selectable
|
|
346
|
+
const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
|
|
347
|
+
return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
|
|
348
|
+
}, [filteredItems, activeItemValue]);
|
|
349
|
+
// Update activeItemValue when active index changes (e.g., when filtering results in auto-focus)
|
|
350
|
+
(0, core_1.useEffect)(() => {
|
|
351
|
+
const activeItem = filteredItems[active];
|
|
352
|
+
if (activeItem && !core_1.Separator.isSeparator(activeItem)) {
|
|
353
|
+
const currentActiveValue = activeItem
|
|
354
|
+
.value;
|
|
355
|
+
if (activeItemValue !== currentActiveValue) {
|
|
356
|
+
setActiveItemValue(currentActiveValue);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}, [active, filteredItems, activeItemValue]);
|
|
179
360
|
const [errorMsg, setError] = (0, core_1.useState)();
|
|
361
|
+
// Hide cursor on mount, show on unmount (like other inquirer prompts)
|
|
362
|
+
(0, core_1.useEffect)(() => {
|
|
363
|
+
// Hide cursor when prompt starts (only in TTY environments)
|
|
364
|
+
if (process.stdout.isTTY) {
|
|
365
|
+
process.stdout.write(ansi_escapes_1.default.cursorHide);
|
|
366
|
+
}
|
|
367
|
+
// Show cursor when prompt ends (cleanup function)
|
|
368
|
+
return () => {
|
|
369
|
+
if (process.stdout.isTTY) {
|
|
370
|
+
process.stdout.write(ansi_escapes_1.default.cursorShow);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}, []);
|
|
180
374
|
// Handle async source - load data based on search term
|
|
181
375
|
(0, core_1.useEffect)(() => {
|
|
182
376
|
if (!config.source) {
|
|
@@ -213,31 +407,14 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
213
407
|
(0, core_1.useEffect)(() => {
|
|
214
408
|
allItemsRef.current = allItems;
|
|
215
409
|
}, [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
410
|
// Keyboard event handling
|
|
240
411
|
(0, core_1.useKeypress)((key, rl) => {
|
|
412
|
+
// Helper function to update search term in both readline and React state
|
|
413
|
+
const updateSearchTerm = (newTerm) => {
|
|
414
|
+
rl.clearLine(0);
|
|
415
|
+
rl.write(newTerm);
|
|
416
|
+
setSearchTerm(newTerm);
|
|
417
|
+
};
|
|
241
418
|
// Allow search input even during loading, but block other actions
|
|
242
419
|
const isNavigationOrAction = key.name === 'up' ||
|
|
243
420
|
key.name === 'down' ||
|
|
@@ -251,13 +428,14 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
251
428
|
setError(undefined);
|
|
252
429
|
// Handle Escape key - clear search term quickly
|
|
253
430
|
if (key.name === 'escape') {
|
|
254
|
-
|
|
255
|
-
setSearchTerm(''); // Then update state
|
|
431
|
+
updateSearchTerm(''); // Clear both readline and React state
|
|
256
432
|
return;
|
|
257
433
|
}
|
|
258
|
-
// Handle navigation
|
|
259
|
-
|
|
260
|
-
|
|
434
|
+
// Handle navigation - ONLY actual arrow keys (not vim j/k keys)
|
|
435
|
+
// This follows the official inquirer.js search approach
|
|
436
|
+
if (key.name === 'up' || key.name === 'down') {
|
|
437
|
+
rl.clearLine(0); // Clean readline state before navigation
|
|
438
|
+
const direction = key.name === 'up' ? -1 : 1;
|
|
261
439
|
const selectableIndexes = filteredItems
|
|
262
440
|
.map((item, index) => ({ item, index }))
|
|
263
441
|
.filter(({ item }) => isSelectable(item))
|
|
@@ -275,14 +453,22 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
275
453
|
else {
|
|
276
454
|
nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
|
|
277
455
|
}
|
|
278
|
-
|
|
456
|
+
// Translate from position inside `selectableIndexes` to the real index
|
|
457
|
+
const nextFilteredIndex = selectableIndexes[nextSelectableIndex];
|
|
458
|
+
const nextSelectableItem = filteredItems[nextFilteredIndex];
|
|
459
|
+
if (nextSelectableItem && isSelectable(nextSelectableItem)) {
|
|
460
|
+
setActiveItemValue(nextSelectableItem.value);
|
|
461
|
+
}
|
|
279
462
|
return;
|
|
280
463
|
}
|
|
281
|
-
// Handle selection toggle with tab key
|
|
464
|
+
// Handle selection toggle with tab key
|
|
282
465
|
if (key.name === 'tab') {
|
|
466
|
+
const preservedSearchTerm = searchTerm;
|
|
283
467
|
const activeItem = filteredItems[active];
|
|
284
468
|
if (activeItem && isSelectable(activeItem)) {
|
|
285
469
|
const activeValue = activeItem.value;
|
|
470
|
+
// Set this as the active item value so cursor position is preserved
|
|
471
|
+
setActiveItemValue(activeValue);
|
|
286
472
|
setAllItems(allItems.map((item) => {
|
|
287
473
|
// Compare by value only for robust matching
|
|
288
474
|
if (!core_1.Separator.isSeparator(item) && item.value === activeValue) {
|
|
@@ -292,7 +478,10 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
292
478
|
return item;
|
|
293
479
|
}));
|
|
294
480
|
}
|
|
295
|
-
|
|
481
|
+
updateSearchTerm(preservedSearchTerm);
|
|
482
|
+
// return to prevent tab from affecting search text:
|
|
483
|
+
// Readline's tab completion in @inquirer/core can modify rl.line, adding spaces to the search text
|
|
484
|
+
return;
|
|
296
485
|
}
|
|
297
486
|
// Handle submission
|
|
298
487
|
if ((0, core_1.isEnterKey)(key)) {
|
|
@@ -336,9 +525,18 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
336
525
|
// Handle all other input as search term updates EXCEPT tab
|
|
337
526
|
// Only update search term for actual typing, not navigation keys
|
|
338
527
|
if (!isNavigationOrAction) {
|
|
528
|
+
// For general input, only update React state since rl.line is already current
|
|
339
529
|
setSearchTerm(rl.line);
|
|
340
530
|
}
|
|
341
531
|
});
|
|
532
|
+
// Calculate the active item's description using useMemo for better React patterns
|
|
533
|
+
const activeDescription = (0, core_1.useMemo)(() => {
|
|
534
|
+
const activeItem = filteredItems[active];
|
|
535
|
+
if (activeItem && !core_1.Separator.isSeparator(activeItem)) {
|
|
536
|
+
return activeItem.description;
|
|
537
|
+
}
|
|
538
|
+
return undefined;
|
|
539
|
+
}, [active, filteredItems]);
|
|
342
540
|
// Create renderItem function that's reactive to current state
|
|
343
541
|
const renderItem = (0, core_1.useMemo)(() => {
|
|
344
542
|
return ({ item, isActive }) => {
|
|
@@ -363,32 +561,20 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
363
561
|
let text = item.name;
|
|
364
562
|
if (isActive) {
|
|
365
563
|
text = theme.style.highlight(text);
|
|
564
|
+
// NOTE: Description is now calculated via useMemo, not side-effect mutation
|
|
366
565
|
}
|
|
367
566
|
else if (item.disabled) {
|
|
368
567
|
text = theme.style.disabled(text);
|
|
369
568
|
}
|
|
370
569
|
line.push(text);
|
|
371
|
-
// Show disabled reason if item is disabled
|
|
570
|
+
// Show disabled reason if item is disabled (but no descriptions inline anymore)
|
|
372
571
|
if (item.disabled) {
|
|
373
572
|
const disabledReason = typeof item.disabled === 'string'
|
|
374
573
|
? item.disabled
|
|
375
574
|
: 'disabled';
|
|
376
575
|
line.push(theme.style.disabled(`(${disabledReason})`));
|
|
377
576
|
}
|
|
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
|
-
}
|
|
577
|
+
// NOTE: Removed the inline description display - descriptions now appear at bottom
|
|
392
578
|
return line.join(' ');
|
|
393
579
|
};
|
|
394
580
|
}, [allItems, theme, config.theme]);
|
|
@@ -403,12 +589,18 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
403
589
|
// Render the prompt
|
|
404
590
|
const message = theme.style.message(config.message, status);
|
|
405
591
|
let helpTip = '';
|
|
406
|
-
if (theme.helpMode === 'always') {
|
|
407
|
-
|
|
408
|
-
|
|
592
|
+
if (theme.helpMode === 'always' && config.instructions !== false) {
|
|
593
|
+
if (typeof config.instructions === 'string') {
|
|
594
|
+
helpTip = `\n${theme.style.help(`(${config.instructions})`)}`;
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
const tips = ['Tab to select', 'Enter to submit'];
|
|
598
|
+
helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
|
|
599
|
+
}
|
|
409
600
|
}
|
|
410
601
|
let searchLine = '';
|
|
411
|
-
|
|
602
|
+
// Always show search line when search functionality is available (static choices or async source)
|
|
603
|
+
if (config.source || config.choices || searchTerm || status === 'loading') {
|
|
412
604
|
const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
|
|
413
605
|
const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
|
|
414
606
|
searchLine = `\n${searchPrefix} ${styledTerm}`;
|
|
@@ -430,7 +622,12 @@ exports.default = (0, core_1.createPrompt)((config, done) => {
|
|
|
430
622
|
else {
|
|
431
623
|
content = `\n${page}`;
|
|
432
624
|
}
|
|
433
|
-
|
|
625
|
+
// Add description of active item at the bottom (like original inquirer.js)
|
|
626
|
+
let descriptionLine = '';
|
|
627
|
+
if (activeDescription) {
|
|
628
|
+
descriptionLine = `\n${theme.style.description(activeDescription)}`;
|
|
629
|
+
}
|
|
630
|
+
return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${descriptionLine}`;
|
|
434
631
|
});
|
|
435
632
|
// Re-export Separator for convenience
|
|
436
633
|
var core_2 = require("@inquirer/core");
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -44,6 +44,49 @@ export type NormalizedChoice<Value> = {
|
|
|
44
44
|
disabled: boolean | string;
|
|
45
45
|
checked: boolean;
|
|
46
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Configuration options for the checkbox-search prompt
|
|
49
|
+
*/
|
|
50
|
+
export type PageSizeConfig = {
|
|
51
|
+
base?: number;
|
|
52
|
+
max?: number;
|
|
53
|
+
min?: number;
|
|
54
|
+
autoBufferDescriptions?: boolean;
|
|
55
|
+
buffer?: number;
|
|
56
|
+
minBuffer?: number;
|
|
57
|
+
autoBufferCountsLineWidth?: boolean;
|
|
58
|
+
};
|
|
59
|
+
export type PageSize = number | PageSizeConfig;
|
|
60
|
+
/**
|
|
61
|
+
* Internal item type (choice or separator)
|
|
62
|
+
*/
|
|
63
|
+
type Item<Value> = NormalizedChoice<Value> | Separator;
|
|
64
|
+
/**
|
|
65
|
+
* Validate PageSizeConfig object for correctness
|
|
66
|
+
* @param config - PageSizeConfig to validate
|
|
67
|
+
* @throws Error if validation fails
|
|
68
|
+
*/
|
|
69
|
+
export declare function validatePageSizeConfig(config: PageSizeConfig): void;
|
|
70
|
+
/**
|
|
71
|
+
* Calculate the maximum number of lines needed for descriptions across all items
|
|
72
|
+
* @param items - Array of items to analyze
|
|
73
|
+
* @param countLineWidth - Whether to consider terminal width for line wrapping
|
|
74
|
+
* @returns Maximum lines needed by any description
|
|
75
|
+
*/
|
|
76
|
+
export declare function calculateDescriptionLines<Value>(items: readonly Item<Value>[], countLineWidth: boolean): number;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve PageSize configuration to a final numeric page size
|
|
79
|
+
* @param pageSize - PageSize configuration (number or PageSizeConfig)
|
|
80
|
+
* @param items - Current items to consider for auto-buffering
|
|
81
|
+
* @returns Final resolved page size
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolvePageSize<Value>(pageSize: PageSize, items: readonly Item<Value>[]): number;
|
|
84
|
+
/**
|
|
85
|
+
* Calculate dynamic page size based on terminal height
|
|
86
|
+
* @param fallbackPageSize - Default page size to use if terminal height is not available
|
|
87
|
+
* @returns Calculated page size
|
|
88
|
+
*/
|
|
89
|
+
export declare function calculateDynamicPageSize(fallbackPageSize: number): number;
|
|
47
90
|
/**
|
|
48
91
|
* Main checkbox-search prompt implementation
|
|
49
92
|
*
|
|
@@ -63,7 +106,7 @@ export type NormalizedChoice<Value> = {
|
|
|
63
106
|
declare const _default: <Value>(config: {
|
|
64
107
|
message: string;
|
|
65
108
|
prefix?: string | undefined;
|
|
66
|
-
pageSize?:
|
|
109
|
+
pageSize?: PageSize | undefined;
|
|
67
110
|
instructions?: string | boolean | undefined;
|
|
68
111
|
choices?: readonly (string | Separator)[] | readonly (Separator | Choice<Value>)[] | undefined;
|
|
69
112
|
source?: ((term: string | undefined, opt: {
|
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,142 @@ function defaultFilter(items, term) {
|
|
|
98
98
|
value.includes(searchTerm));
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Validate PageSizeConfig object for correctness
|
|
103
|
+
* @param config - PageSizeConfig to validate
|
|
104
|
+
* @throws Error if validation fails
|
|
105
|
+
*/
|
|
106
|
+
export function validatePageSizeConfig(config) {
|
|
107
|
+
if (config.min !== undefined && config.min < 1) {
|
|
108
|
+
throw new Error('PageSize min cannot be less than 1');
|
|
109
|
+
}
|
|
110
|
+
if (config.base !== undefined && config.base < 1) {
|
|
111
|
+
throw new Error('PageSize base cannot be less than 1');
|
|
112
|
+
}
|
|
113
|
+
if (config.buffer !== undefined && config.buffer < 0) {
|
|
114
|
+
throw new Error('PageSize buffer cannot be negative');
|
|
115
|
+
}
|
|
116
|
+
if (config.minBuffer !== undefined && config.minBuffer < 0) {
|
|
117
|
+
throw new Error('PageSize minBuffer cannot be negative');
|
|
118
|
+
}
|
|
119
|
+
if (config.min !== undefined &&
|
|
120
|
+
config.max !== undefined &&
|
|
121
|
+
config.min > config.max) {
|
|
122
|
+
throw new Error(`PageSize min (${config.min}) cannot be greater than max (${config.max})`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Calculate the maximum number of lines needed for descriptions across all items
|
|
127
|
+
* @param items - Array of items to analyze
|
|
128
|
+
* @param countLineWidth - Whether to consider terminal width for line wrapping
|
|
129
|
+
* @returns Maximum lines needed by any description
|
|
130
|
+
*/
|
|
131
|
+
export function calculateDescriptionLines(items, countLineWidth) {
|
|
132
|
+
let maxLines = 0;
|
|
133
|
+
for (const item of items) {
|
|
134
|
+
if (Separator.isSeparator(item) || !item.description) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
let lines;
|
|
138
|
+
if (countLineWidth) {
|
|
139
|
+
// Consider terminal width for wrapping
|
|
140
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
141
|
+
const descriptionLines = item.description.split('\n');
|
|
142
|
+
lines = descriptionLines.reduce((total, line) => {
|
|
143
|
+
return total + (Math.ceil(line.length / terminalWidth) || 1);
|
|
144
|
+
}, 0);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Simple newline counting
|
|
148
|
+
lines = item.description.split('\n').length;
|
|
149
|
+
}
|
|
150
|
+
maxLines = Math.max(maxLines, lines);
|
|
151
|
+
}
|
|
152
|
+
return maxLines;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Resolve PageSize configuration to a final numeric page size
|
|
156
|
+
* @param pageSize - PageSize configuration (number or PageSizeConfig)
|
|
157
|
+
* @param items - Current items to consider for auto-buffering
|
|
158
|
+
* @returns Final resolved page size
|
|
159
|
+
*/
|
|
160
|
+
export function resolvePageSize(pageSize, items) {
|
|
161
|
+
// Handle simple number case (backward compatibility)
|
|
162
|
+
if (typeof pageSize === 'number') {
|
|
163
|
+
return pageSize;
|
|
164
|
+
}
|
|
165
|
+
// Validate the configuration
|
|
166
|
+
validatePageSizeConfig(pageSize);
|
|
167
|
+
// Step 1: Determine base page size
|
|
168
|
+
let basePageSize;
|
|
169
|
+
if (pageSize.base !== undefined) {
|
|
170
|
+
basePageSize = pageSize.base;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// Auto-calculate using existing logic
|
|
174
|
+
basePageSize = calculateDynamicPageSize(7);
|
|
175
|
+
}
|
|
176
|
+
// Step 2: Calculate buffer reduction
|
|
177
|
+
let buffer = 0;
|
|
178
|
+
// 2a: Start at 0 ✓
|
|
179
|
+
// 2b: If autoBufferDescriptions, add max description lines
|
|
180
|
+
if (pageSize.autoBufferDescriptions) {
|
|
181
|
+
buffer += calculateDescriptionLines(items, pageSize.autoBufferCountsLineWidth || false);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// 2c: Add buffer value (only if not auto-buffering)
|
|
185
|
+
buffer += pageSize.buffer || 0;
|
|
186
|
+
}
|
|
187
|
+
// 2d: Ensure at least minBuffer
|
|
188
|
+
if (pageSize.minBuffer !== undefined) {
|
|
189
|
+
buffer = Math.max(buffer, pageSize.minBuffer);
|
|
190
|
+
}
|
|
191
|
+
// Step 3: Subtract buffer from base
|
|
192
|
+
let finalPageSize = basePageSize - buffer;
|
|
193
|
+
// Step 4: Apply min/max constraints
|
|
194
|
+
if (pageSize.min !== undefined) {
|
|
195
|
+
finalPageSize = Math.max(finalPageSize, pageSize.min);
|
|
196
|
+
}
|
|
197
|
+
if (pageSize.max !== undefined) {
|
|
198
|
+
finalPageSize = Math.min(finalPageSize, pageSize.max);
|
|
199
|
+
}
|
|
200
|
+
// Ensure minimum of 1
|
|
201
|
+
return Math.max(1, finalPageSize);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Calculate dynamic page size based on terminal height
|
|
205
|
+
* @param fallbackPageSize - Default page size to use if terminal height is not available
|
|
206
|
+
* @returns Calculated page size
|
|
207
|
+
*/
|
|
208
|
+
export function calculateDynamicPageSize(fallbackPageSize) {
|
|
209
|
+
let rawPageSize;
|
|
210
|
+
try {
|
|
211
|
+
// Get terminal height from process.stdout.rows
|
|
212
|
+
const terminalHeight = process.stdout.rows;
|
|
213
|
+
if (!terminalHeight || terminalHeight < 1) {
|
|
214
|
+
// Fallback to static page size if terminal height is not available
|
|
215
|
+
rawPageSize = fallbackPageSize;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
// Reserve space for UI elements:
|
|
219
|
+
// - 1 line for the prompt message
|
|
220
|
+
// - 1 line for help instructions
|
|
221
|
+
// - 1 line for search input (if present)
|
|
222
|
+
// - 1 line for error messages (if present)
|
|
223
|
+
// - 1 line for description (if present)
|
|
224
|
+
// - 1 line for buffer/spacing
|
|
225
|
+
const reservedLines = 6;
|
|
226
|
+
// Calculate available lines for choices
|
|
227
|
+
rawPageSize = terminalHeight - reservedLines;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// If there's any error accessing terminal dimensions, fallback gracefully
|
|
232
|
+
rawPageSize = fallbackPageSize;
|
|
233
|
+
}
|
|
234
|
+
// Ensure minimum page size for usability and cap maximum to prevent overwhelming display
|
|
235
|
+
return Math.max(2, Math.min(rawPageSize, 50));
|
|
236
|
+
}
|
|
101
237
|
/**
|
|
102
238
|
* Main checkbox-search prompt implementation
|
|
103
239
|
*
|
|
@@ -118,7 +254,7 @@ export default createPrompt((config, done) => {
|
|
|
118
254
|
// Stable reference for empty array to prevent unnecessary recalculations
|
|
119
255
|
const emptyArray = useMemo(() => [], []);
|
|
120
256
|
// Configuration with defaults
|
|
121
|
-
const { pageSize
|
|
257
|
+
const { pageSize: configPageSize, loop = true, required, validate = () => true, default: defaultValues = emptyArray, } = config;
|
|
122
258
|
const theme = makeTheme(checkboxSearchTheme, config.theme);
|
|
123
259
|
// State management hooks
|
|
124
260
|
const [status, setStatus] = useState('idle');
|
|
@@ -141,6 +277,20 @@ export default createPrompt((config, done) => {
|
|
|
141
277
|
}
|
|
142
278
|
return [];
|
|
143
279
|
});
|
|
280
|
+
// Calculate effective page size (memoized with terminal size tracking)
|
|
281
|
+
// Use new resolvePageSize function to handle both number and PageSizeConfig
|
|
282
|
+
const terminalHeight = process.stdout.rows; // Track terminal size for memoization
|
|
283
|
+
const pageSize = useMemo(() => {
|
|
284
|
+
if (configPageSize !== undefined) {
|
|
285
|
+
return resolvePageSize(configPageSize, allItems);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// Default behavior - auto-calculate
|
|
289
|
+
return calculateDynamicPageSize(7);
|
|
290
|
+
}
|
|
291
|
+
}, [configPageSize, terminalHeight, allItems]);
|
|
292
|
+
// Store the active item value instead of active index
|
|
293
|
+
const [activeItemValue, setActiveItemValue] = useState(null);
|
|
144
294
|
// Compute filtered items based on search term and filtering logic
|
|
145
295
|
const filteredItems = useMemo(() => {
|
|
146
296
|
// Async source mode - use allItems directly (source handles filtering)
|
|
@@ -169,8 +319,48 @@ export default createPrompt((config, done) => {
|
|
|
169
319
|
}
|
|
170
320
|
return result;
|
|
171
321
|
}, [allItems, searchTerm, config.source, config.filter]);
|
|
172
|
-
|
|
322
|
+
// Compute active index from activeItemValue
|
|
323
|
+
const active = useMemo(() => {
|
|
324
|
+
if (activeItemValue === null) {
|
|
325
|
+
// No active item set, default to first selectable
|
|
326
|
+
const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
|
|
327
|
+
return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
|
|
328
|
+
}
|
|
329
|
+
// Find the item with the active value
|
|
330
|
+
const activeIndex = filteredItems.findIndex((item) => !Separator.isSeparator(item) &&
|
|
331
|
+
item.value === activeItemValue);
|
|
332
|
+
if (activeIndex !== -1) {
|
|
333
|
+
return activeIndex;
|
|
334
|
+
}
|
|
335
|
+
// Active item not found in filtered list, default to first selectable
|
|
336
|
+
const firstSelectableIndex = filteredItems.findIndex((item) => isSelectable(item));
|
|
337
|
+
return firstSelectableIndex !== -1 ? firstSelectableIndex : 0;
|
|
338
|
+
}, [filteredItems, activeItemValue]);
|
|
339
|
+
// Update activeItemValue when active index changes (e.g., when filtering results in auto-focus)
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
const activeItem = filteredItems[active];
|
|
342
|
+
if (activeItem && !Separator.isSeparator(activeItem)) {
|
|
343
|
+
const currentActiveValue = activeItem
|
|
344
|
+
.value;
|
|
345
|
+
if (activeItemValue !== currentActiveValue) {
|
|
346
|
+
setActiveItemValue(currentActiveValue);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}, [active, filteredItems, activeItemValue]);
|
|
173
350
|
const [errorMsg, setError] = useState();
|
|
351
|
+
// Hide cursor on mount, show on unmount (like other inquirer prompts)
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
// Hide cursor when prompt starts (only in TTY environments)
|
|
354
|
+
if (process.stdout.isTTY) {
|
|
355
|
+
process.stdout.write(ansiEscapes.cursorHide);
|
|
356
|
+
}
|
|
357
|
+
// Show cursor when prompt ends (cleanup function)
|
|
358
|
+
return () => {
|
|
359
|
+
if (process.stdout.isTTY) {
|
|
360
|
+
process.stdout.write(ansiEscapes.cursorShow);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}, []);
|
|
174
364
|
// Handle async source - load data based on search term
|
|
175
365
|
useEffect(() => {
|
|
176
366
|
if (!config.source) {
|
|
@@ -207,31 +397,14 @@ export default createPrompt((config, done) => {
|
|
|
207
397
|
useEffect(() => {
|
|
208
398
|
allItemsRef.current = allItems;
|
|
209
399
|
}, [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
400
|
// Keyboard event handling
|
|
234
401
|
useKeypress((key, rl) => {
|
|
402
|
+
// Helper function to update search term in both readline and React state
|
|
403
|
+
const updateSearchTerm = (newTerm) => {
|
|
404
|
+
rl.clearLine(0);
|
|
405
|
+
rl.write(newTerm);
|
|
406
|
+
setSearchTerm(newTerm);
|
|
407
|
+
};
|
|
235
408
|
// Allow search input even during loading, but block other actions
|
|
236
409
|
const isNavigationOrAction = key.name === 'up' ||
|
|
237
410
|
key.name === 'down' ||
|
|
@@ -245,13 +418,14 @@ export default createPrompt((config, done) => {
|
|
|
245
418
|
setError(undefined);
|
|
246
419
|
// Handle Escape key - clear search term quickly
|
|
247
420
|
if (key.name === 'escape') {
|
|
248
|
-
|
|
249
|
-
setSearchTerm(''); // Then update state
|
|
421
|
+
updateSearchTerm(''); // Clear both readline and React state
|
|
250
422
|
return;
|
|
251
423
|
}
|
|
252
|
-
// Handle navigation
|
|
253
|
-
|
|
254
|
-
|
|
424
|
+
// Handle navigation - ONLY actual arrow keys (not vim j/k keys)
|
|
425
|
+
// This follows the official inquirer.js search approach
|
|
426
|
+
if (key.name === 'up' || key.name === 'down') {
|
|
427
|
+
rl.clearLine(0); // Clean readline state before navigation
|
|
428
|
+
const direction = key.name === 'up' ? -1 : 1;
|
|
255
429
|
const selectableIndexes = filteredItems
|
|
256
430
|
.map((item, index) => ({ item, index }))
|
|
257
431
|
.filter(({ item }) => isSelectable(item))
|
|
@@ -269,14 +443,22 @@ export default createPrompt((config, done) => {
|
|
|
269
443
|
else {
|
|
270
444
|
nextSelectableIndex = Math.max(0, Math.min(nextSelectableIndex, selectableIndexes.length - 1));
|
|
271
445
|
}
|
|
272
|
-
|
|
446
|
+
// Translate from position inside `selectableIndexes` to the real index
|
|
447
|
+
const nextFilteredIndex = selectableIndexes[nextSelectableIndex];
|
|
448
|
+
const nextSelectableItem = filteredItems[nextFilteredIndex];
|
|
449
|
+
if (nextSelectableItem && isSelectable(nextSelectableItem)) {
|
|
450
|
+
setActiveItemValue(nextSelectableItem.value);
|
|
451
|
+
}
|
|
273
452
|
return;
|
|
274
453
|
}
|
|
275
|
-
// Handle selection toggle with tab key
|
|
454
|
+
// Handle selection toggle with tab key
|
|
276
455
|
if (key.name === 'tab') {
|
|
456
|
+
const preservedSearchTerm = searchTerm;
|
|
277
457
|
const activeItem = filteredItems[active];
|
|
278
458
|
if (activeItem && isSelectable(activeItem)) {
|
|
279
459
|
const activeValue = activeItem.value;
|
|
460
|
+
// Set this as the active item value so cursor position is preserved
|
|
461
|
+
setActiveItemValue(activeValue);
|
|
280
462
|
setAllItems(allItems.map((item) => {
|
|
281
463
|
// Compare by value only for robust matching
|
|
282
464
|
if (!Separator.isSeparator(item) && item.value === activeValue) {
|
|
@@ -286,7 +468,10 @@ export default createPrompt((config, done) => {
|
|
|
286
468
|
return item;
|
|
287
469
|
}));
|
|
288
470
|
}
|
|
289
|
-
|
|
471
|
+
updateSearchTerm(preservedSearchTerm);
|
|
472
|
+
// return to prevent tab from affecting search text:
|
|
473
|
+
// Readline's tab completion in @inquirer/core can modify rl.line, adding spaces to the search text
|
|
474
|
+
return;
|
|
290
475
|
}
|
|
291
476
|
// Handle submission
|
|
292
477
|
if (isEnterKey(key)) {
|
|
@@ -330,9 +515,18 @@ export default createPrompt((config, done) => {
|
|
|
330
515
|
// Handle all other input as search term updates EXCEPT tab
|
|
331
516
|
// Only update search term for actual typing, not navigation keys
|
|
332
517
|
if (!isNavigationOrAction) {
|
|
518
|
+
// For general input, only update React state since rl.line is already current
|
|
333
519
|
setSearchTerm(rl.line);
|
|
334
520
|
}
|
|
335
521
|
});
|
|
522
|
+
// Calculate the active item's description using useMemo for better React patterns
|
|
523
|
+
const activeDescription = useMemo(() => {
|
|
524
|
+
const activeItem = filteredItems[active];
|
|
525
|
+
if (activeItem && !Separator.isSeparator(activeItem)) {
|
|
526
|
+
return activeItem.description;
|
|
527
|
+
}
|
|
528
|
+
return undefined;
|
|
529
|
+
}, [active, filteredItems]);
|
|
336
530
|
// Create renderItem function that's reactive to current state
|
|
337
531
|
const renderItem = useMemo(() => {
|
|
338
532
|
return ({ item, isActive }) => {
|
|
@@ -357,32 +551,20 @@ export default createPrompt((config, done) => {
|
|
|
357
551
|
let text = item.name;
|
|
358
552
|
if (isActive) {
|
|
359
553
|
text = theme.style.highlight(text);
|
|
554
|
+
// NOTE: Description is now calculated via useMemo, not side-effect mutation
|
|
360
555
|
}
|
|
361
556
|
else if (item.disabled) {
|
|
362
557
|
text = theme.style.disabled(text);
|
|
363
558
|
}
|
|
364
559
|
line.push(text);
|
|
365
|
-
// Show disabled reason if item is disabled
|
|
560
|
+
// Show disabled reason if item is disabled (but no descriptions inline anymore)
|
|
366
561
|
if (item.disabled) {
|
|
367
562
|
const disabledReason = typeof item.disabled === 'string'
|
|
368
563
|
? item.disabled
|
|
369
564
|
: 'disabled';
|
|
370
565
|
line.push(theme.style.disabled(`(${disabledReason})`));
|
|
371
566
|
}
|
|
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
|
-
}
|
|
567
|
+
// NOTE: Removed the inline description display - descriptions now appear at bottom
|
|
386
568
|
return line.join(' ');
|
|
387
569
|
};
|
|
388
570
|
}, [allItems, theme, config.theme]);
|
|
@@ -397,12 +579,18 @@ export default createPrompt((config, done) => {
|
|
|
397
579
|
// Render the prompt
|
|
398
580
|
const message = theme.style.message(config.message, status);
|
|
399
581
|
let helpTip = '';
|
|
400
|
-
if (theme.helpMode === 'always') {
|
|
401
|
-
|
|
402
|
-
|
|
582
|
+
if (theme.helpMode === 'always' && config.instructions !== false) {
|
|
583
|
+
if (typeof config.instructions === 'string') {
|
|
584
|
+
helpTip = `\n${theme.style.help(`(${config.instructions})`)}`;
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
const tips = ['Tab to select', 'Enter to submit'];
|
|
588
|
+
helpTip = `\n${theme.style.help(`(${tips.join(', ')})`)}`;
|
|
589
|
+
}
|
|
403
590
|
}
|
|
404
591
|
let searchLine = '';
|
|
405
|
-
|
|
592
|
+
// Always show search line when search functionality is available (static choices or async source)
|
|
593
|
+
if (config.source || config.choices || searchTerm || status === 'loading') {
|
|
406
594
|
const searchPrefix = status === 'loading' ? 'Loading...' : 'Search:';
|
|
407
595
|
const styledTerm = searchTerm ? theme.style.searchTerm(searchTerm) : '';
|
|
408
596
|
searchLine = `\n${searchPrefix} ${styledTerm}`;
|
|
@@ -424,7 +612,12 @@ export default createPrompt((config, done) => {
|
|
|
424
612
|
else {
|
|
425
613
|
content = `\n${page}`;
|
|
426
614
|
}
|
|
427
|
-
|
|
615
|
+
// Add description of active item at the bottom (like original inquirer.js)
|
|
616
|
+
let descriptionLine = '';
|
|
617
|
+
if (activeDescription) {
|
|
618
|
+
descriptionLine = `\n${theme.style.description(activeDescription)}`;
|
|
619
|
+
}
|
|
620
|
+
return `${prefix} ${message}${helpTip}${searchLine}${errorLine}${content}${descriptionLine}`;
|
|
428
621
|
});
|
|
429
622
|
// Re-export Separator for convenience
|
|
430
623
|
export { Separator } from '@inquirer/core';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inquirerjs-checkbox-search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A multi-select prompt with text filtering for inquirer.js",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"answer",
|
|
@@ -64,14 +64,16 @@
|
|
|
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",
|
|
71
71
|
"format": "prettier --write .",
|
|
72
72
|
"format:check": "prettier --check .",
|
|
73
|
-
"
|
|
74
|
-
"
|
|
73
|
+
"quality": "npm run format && npm run lint && npm run typecheck",
|
|
74
|
+
"quality:check": "npm run format:check && npm run lint:check && npm run typecheck",
|
|
75
|
+
"test": "npm run quality && npm run test:unit",
|
|
76
|
+
"test:ci": "npm run quality:check && npm run test:coverage",
|
|
75
77
|
"test:unit": "vitest run",
|
|
76
78
|
"test:coverage": "vitest run --coverage",
|
|
77
79
|
"test:ui": "vitest --ui",
|
|
@@ -84,7 +86,7 @@
|
|
|
84
86
|
"@inquirer/core": "^10.1.13",
|
|
85
87
|
"@inquirer/figures": "^1.0.12",
|
|
86
88
|
"@inquirer/type": "^3.0.7",
|
|
87
|
-
"ansi-escapes": "^
|
|
89
|
+
"ansi-escapes": "^7.0.0",
|
|
88
90
|
"yoctocolors-cjs": "^2.1.2"
|
|
89
91
|
},
|
|
90
92
|
"devDependencies": {
|
|
@@ -93,15 +95,15 @@
|
|
|
93
95
|
"@types/node": "^20.0.0",
|
|
94
96
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
95
97
|
"@typescript-eslint/parser": "^8.0.0",
|
|
96
|
-
"@vitest/coverage-v8": "^2.
|
|
97
|
-
"@vitest/ui": "^2.
|
|
98
|
+
"@vitest/coverage-v8": "^3.2.3",
|
|
99
|
+
"@vitest/ui": "^3.2.3",
|
|
98
100
|
"eslint": "^9.0.0",
|
|
99
101
|
"eslint-config-prettier": "^9.0.0",
|
|
100
102
|
"prettier": "^3.0.0",
|
|
101
103
|
"rimraf": "^6.0.0",
|
|
102
104
|
"tshy": "^3.0.2",
|
|
103
105
|
"typescript": "^5.6.0",
|
|
104
|
-
"vitest": "^2.
|
|
106
|
+
"vitest": "^3.2.3"
|
|
105
107
|
},
|
|
106
108
|
"engines": {
|
|
107
109
|
"node": ">=18"
|