Skip to content

Commit 9bd8072

Browse files
authored
feat: add required option to autocomplete multiselect (#329)
1 parent 8ead5d3 commit 9bd8072

File tree

4 files changed

+152
-37
lines changed

4 files changed

+152
-37
lines changed

.changeset/strong-ravens-greet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": minor
3+
---
4+
5+
Add a `required` option to autocomplete multiselect.

packages/prompts/src/autocomplete.ts

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ function getSelectedOptions<T>(values: T[], options: Option<T>[]): Option<T>[] {
4141
return results;
4242
}
4343

44-
export interface AutocompleteOptions<Value> extends CommonOptions {
44+
interface AutocompleteSharedOptions<Value> extends CommonOptions {
4545
/**
4646
* The message to display to the user.
4747
*/
@@ -50,10 +50,6 @@ export interface AutocompleteOptions<Value> extends CommonOptions {
5050
* Available options for the autocomplete prompt.
5151
*/
5252
options: Option<Value>[];
53-
/**
54-
* The initial selected value.
55-
*/
56-
initialValue?: Value;
5753
/**
5854
* Maximum number of items to display at once.
5955
*/
@@ -64,6 +60,13 @@ export interface AutocompleteOptions<Value> extends CommonOptions {
6460
placeholder?: string;
6561
}
6662

63+
export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Value> {
64+
/**
65+
* The initial selected value.
66+
*/
67+
initialValue?: Value;
68+
}
69+
6770
export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
6871
const prompt = new AutocompletePrompt({
6972
options: opts.options,
@@ -158,35 +161,15 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
158161
};
159162

160163
// Type definition for the autocompleteMultiselect component
161-
export interface AutocompleteMultiSelectOptions<Value> {
162-
/**
163-
* The message to display to the user
164-
*/
165-
message: string;
166-
/**
167-
* The options for the user to choose from
168-
*/
169-
options: Option<Value>[];
164+
export interface AutocompleteMultiSelectOptions<Value> extends AutocompleteSharedOptions<Value> {
170165
/**
171166
* The initial selected values
172167
*/
173168
initialValues?: Value[];
174169
/**
175-
* The maximum number of items that can be selected
176-
*/
177-
maxItems?: number;
178-
/**
179-
* The placeholder to display in the input
180-
*/
181-
placeholder?: string;
182-
/**
183-
* The stream to read from
184-
*/
185-
input?: NodeJS.ReadStream;
186-
/**
187-
* The stream to write to
170+
* If true, at least one option must be selected
188171
*/
189-
output?: NodeJS.WriteStream;
172+
required?: boolean;
190173
}
191174

192175
/**
@@ -220,6 +203,12 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
220203
filter: (search, opt) => {
221204
return getFilteredOption(search, opt);
222205
},
206+
validate: () => {
207+
if (opts.required && prompt.selectedValues.length === 0) {
208+
return 'Please select at least one item';
209+
}
210+
return undefined;
211+
},
223212
placeholder: opts.placeholder,
224213
initialValue: opts.initialValues,
225214
input: opts.input,
@@ -229,10 +218,6 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
229218
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
230219

231220
// Selection counter
232-
const counter =
233-
this.selectedValues.length > 0
234-
? color.cyan(` (${this.selectedValues.length} selected)`)
235-
: '';
236221
const value = String(this.value ?? '');
237222

238223
// Search input display
@@ -270,6 +255,9 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
270255
? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`]
271256
: [];
272257

258+
const errorMessage =
259+
this.state === 'error' ? [`${color.cyan(S_BAR)} ${color.yellow(this.error)}`] : [];
260+
273261
// Get limited options for display
274262
const displayOptions = limitOptions({
275263
cursor: this.cursor,
@@ -285,6 +273,7 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
285273
title,
286274
`${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`,
287275
...noResults,
276+
...errorMessage,
288277
...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
289278
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
290279
`${color.cyan(S_BAR_END)}`,

packages/prompts/test/__snapshots__/autocomplete.test.ts.snap

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = `
1515
│ ...
1616
│ ↑/↓ to select • Enter: confirm • Type: to search
1717
└",
18+
"<cursor.backward count=999><cursor.up count=11>",
19+
"<cursor.down count=1>",
20+
"<erase.down>",
21+
"◇ Select an option
22+
│ Option 0",
23+
"
24+
",
25+
"<cursor.show>",
1826
]
1927
`;
2028
@@ -32,6 +40,14 @@ exports[`autocomplete > renders initial UI with message and instructions 1`] = `
3240
│ ○ Orange
3341
│ ↑/↓ to select • Enter: confirm • Type: to search
3442
└",
43+
"<cursor.backward count=999><cursor.up count=10>",
44+
"<cursor.down count=1>",
45+
"<erase.down>",
46+
"◇ Select a fruit
47+
│ Apple",
48+
"
49+
",
50+
"<cursor.show>",
3551
]
3652
`;
3753
@@ -96,6 +112,14 @@ exports[`autocomplete > shows hint when option has hint and is focused 1`] = `
96112
│ ● Kiwi (New Zealand)
97113
│ ↑/↓ to select • Enter: confirm • Type: to search
98114
└",
115+
"<cursor.backward count=999><cursor.up count=11>",
116+
"<cursor.down count=1>",
117+
"<erase.down>",
118+
"◇ Select a fruit
119+
│ Kiwi",
120+
"
121+
",
122+
"<cursor.show>",
99123
]
100124
`;
101125
@@ -120,6 +144,14 @@ exports[`autocomplete > shows no matches message when search has no results 1`]
120144
│ No matches found
121145
│ ↑/↓ to select • Enter: confirm • Type: to search
122146
└",
147+
"<cursor.backward count=999><cursor.up count=6>",
148+
"<cursor.down count=1>",
149+
"<erase.down>",
150+
"◇ Select a fruit
151+
│ Apple",
152+
"
153+
",
154+
"<cursor.show>",
123155
]
124156
`;
125157
@@ -208,3 +240,55 @@ exports[`autocomplete > supports initialValue 1`] = `
208240
"<cursor.show>",
209241
]
210242
`;
243+
244+
exports[`autocompleteMultiselect > renders error when empty selection & required is true 1`] = `
245+
[
246+
"<cursor.hide>",
247+
"│
248+
◆ Select a fruit
249+
250+
│ Search: _
251+
│ ◻ Apple
252+
│ ◻ Banana
253+
│ ◻ Cherry
254+
│ ◻ Grape
255+
│ ◻ Orange
256+
│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search
257+
└",
258+
"<cursor.backward count=999><cursor.up count=10>",
259+
"<cursor.down count=1>",
260+
"<erase.down>",
261+
"▲ Select a fruit
262+
263+
│ Search: _
264+
│ Please select at least one item
265+
│ ◻ Apple
266+
│ ◻ Banana
267+
│ ◻ Cherry
268+
│ ◻ Grape
269+
│ ◻ Orange
270+
│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search
271+
└",
272+
"<cursor.backward count=999><cursor.up count=11>",
273+
"<cursor.down count=1>",
274+
"<erase.down>",
275+
"◆ Select a fruit
276+
277+
│ Search: _
278+
│ ◼ Apple
279+
│ ◻ Banana
280+
│ ◻ Cherry
281+
│ ◻ Grape
282+
│ ◻ Orange
283+
│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search
284+
└",
285+
"<cursor.backward count=999><cursor.up count=10>",
286+
"<cursor.down count=1>",
287+
"<erase.down>",
288+
"◇ Select a fruit
289+
│ 1 items selected",
290+
"
291+
",
292+
"<cursor.show>",
293+
]
294+
`;

packages/prompts/test/autocomplete.test.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2-
import { autocomplete } from '../src/autocomplete.js';
2+
import { autocomplete, autocompleteMultiselect } from '../src/autocomplete.js';
33
import { MockReadable, MockWritable } from './test-utils.js';
44

55
describe('autocomplete', () => {
@@ -30,9 +30,9 @@ describe('autocomplete', () => {
3030
output,
3131
});
3232

33-
expect(output.buffer).toMatchSnapshot();
3433
input.emit('keypress', '', { name: 'return' });
3534
await result;
35+
expect(output.buffer).toMatchSnapshot();
3636
});
3737

3838
test('limits displayed options when maxItems is set', async () => {
@@ -49,9 +49,9 @@ describe('autocomplete', () => {
4949
output,
5050
});
5151

52-
expect(output.buffer).toMatchSnapshot();
5352
input.emit('keypress', '', { name: 'return' });
5453
await result;
54+
expect(output.buffer).toMatchSnapshot();
5555
});
5656

5757
test('shows no matches message when search has no results', async () => {
@@ -64,9 +64,9 @@ describe('autocomplete', () => {
6464

6565
// Type something that won't match
6666
input.emit('keypress', 'z', { name: 'z' });
67-
expect(output.buffer).toMatchSnapshot();
6867
input.emit('keypress', '', { name: 'return' });
6968
await result;
69+
expect(output.buffer).toMatchSnapshot();
7070
});
7171

7272
test('shows hint when option has hint and is focused', async () => {
@@ -83,9 +83,9 @@ describe('autocomplete', () => {
8383
input.emit('keypress', '', { name: 'down' });
8484
input.emit('keypress', '', { name: 'down' });
8585
input.emit('keypress', '', { name: 'down' });
86-
expect(output.buffer).toMatchSnapshot();
8786
input.emit('keypress', '', { name: 'return' });
8887
await result;
88+
expect(output.buffer).toMatchSnapshot();
8989
});
9090

9191
test('shows selected value in submit state', async () => {
@@ -136,3 +136,40 @@ describe('autocomplete', () => {
136136
expect(output.buffer).toMatchSnapshot();
137137
});
138138
});
139+
140+
describe('autocompleteMultiselect', () => {
141+
let input: MockReadable;
142+
let output: MockWritable;
143+
const testOptions = [
144+
{ value: 'apple', label: 'Apple' },
145+
{ value: 'banana', label: 'Banana' },
146+
{ value: 'cherry', label: 'Cherry' },
147+
{ value: 'grape', label: 'Grape' },
148+
{ value: 'orange', label: 'Orange' },
149+
];
150+
151+
beforeEach(() => {
152+
input = new MockReadable();
153+
output = new MockWritable();
154+
});
155+
156+
afterEach(() => {
157+
vi.restoreAllMocks();
158+
});
159+
160+
test('renders error when empty selection & required is true', async () => {
161+
const result = autocompleteMultiselect({
162+
message: 'Select a fruit',
163+
options: testOptions,
164+
required: true,
165+
input,
166+
output,
167+
});
168+
169+
input.emit('keypress', '', { name: 'return' });
170+
input.emit('keypress', '', { name: 'tab' });
171+
input.emit('keypress', '', { name: 'return' });
172+
await result;
173+
expect(output.buffer).toMatchSnapshot();
174+
});
175+
});

0 commit comments

Comments
 (0)