Skip to content

feat: add required option to autocomplete multiselect #329

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 26, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strong-ravens-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": minor
---

Add a `required` option to autocomplete multiselect.
53 changes: 21 additions & 32 deletions packages/prompts/src/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function getSelectedOptions<T>(values: T[], options: Option<T>[]): Option<T>[] {
return results;
}

export interface AutocompleteOptions<Value> extends CommonOptions {
interface AutocompleteSharedOptions<Value> extends CommonOptions {
/**
* The message to display to the user.
*/
Expand All @@ -50,10 +50,6 @@ export interface AutocompleteOptions<Value> extends CommonOptions {
* Available options for the autocomplete prompt.
*/
options: Option<Value>[];
/**
* The initial selected value.
*/
initialValue?: Value;
/**
* Maximum number of items to display at once.
*/
Expand All @@ -64,6 +60,13 @@ export interface AutocompleteOptions<Value> extends CommonOptions {
placeholder?: string;
}

export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Value> {
/**
* The initial selected value.
*/
initialValue?: Value;
}

export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
const prompt = new AutocompletePrompt({
options: opts.options,
Expand Down Expand Up @@ -158,35 +161,15 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
};

// Type definition for the autocompleteMultiselect component
export interface AutocompleteMultiSelectOptions<Value> {
/**
* The message to display to the user
*/
message: string;
/**
* The options for the user to choose from
*/
options: Option<Value>[];
export interface AutocompleteMultiSelectOptions<Value> extends AutocompleteSharedOptions<Value> {
/**
* The initial selected values
*/
initialValues?: Value[];
/**
* The maximum number of items that can be selected
*/
maxItems?: number;
/**
* The placeholder to display in the input
*/
placeholder?: string;
/**
* The stream to read from
*/
input?: NodeJS.ReadStream;
/**
* The stream to write to
* If true, at least one option must be selected
*/
output?: NodeJS.WriteStream;
required?: boolean;
}

/**
Expand Down Expand Up @@ -220,6 +203,12 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
filter: (search, opt) => {
return getFilteredOption(search, opt);
},
validate: () => {
if (opts.required && prompt.selectedValues.length === 0) {
return 'Please select at least one item';
}
return undefined;
},
placeholder: opts.placeholder,
initialValue: opts.initialValues,
input: opts.input,
Expand All @@ -229,10 +218,6 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;

// Selection counter
const counter =
this.selectedValues.length > 0
? color.cyan(` (${this.selectedValues.length} selected)`)
: '';
const value = String(this.value ?? '');

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

const errorMessage =
this.state === 'error' ? [`${color.cyan(S_BAR)} ${color.yellow(this.error)}`] : [];

// Get limited options for display
const displayOptions = limitOptions({
cursor: this.cursor,
Expand All @@ -285,6 +273,7 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
title,
`${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`,
...noResults,
...errorMessage,
...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
`${color.cyan(S_BAR_END)}`,
Expand Down
84 changes: 84 additions & 0 deletions packages/prompts/test/__snapshots__/autocomplete.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = `
│ ...
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=11>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select an option
│",
"
",
"<cursor.show>",
]
`;

Expand All @@ -32,6 +40,14 @@ exports[`autocomplete > renders initial UI with message and instructions 1`] = `
│ ○ Orange
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=10>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select a fruit
│",
"
",
"<cursor.show>",
]
`;

Expand Down Expand Up @@ -96,6 +112,14 @@ exports[`autocomplete > shows hint when option has hint and is focused 1`] = `
│ ● Kiwi (New Zealand)
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=11>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select a fruit
│ Kiwi",
"
",
"<cursor.show>",
]
`;

Expand All @@ -120,6 +144,14 @@ exports[`autocomplete > shows no matches message when search has no results 1`]
│ No matches found
│ ↑/↓ to select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=6>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select a fruit
│",
"
",
"<cursor.show>",
]
`;

Expand Down Expand Up @@ -183,3 +215,55 @@ exports[`autocomplete > shows strikethrough in cancel state 1`] = `
"<cursor.show>",
]
`;

exports[`autocompleteMultiselect > renders error when empty selection & required is true 1`] = `
[
"<cursor.hide>",
"│
◆ Select a fruit

│ Search: _
│ ◻ Apple
│ ◻ Banana
│ ◻ Cherry
│ ◻ Grape
│ ◻ Orange
│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=10>",
"<cursor.down count=1>",
"<erase.down>",
"▲ Select a fruit

│ Search: _
│ Please select at least one item
│ ◻ Apple
│ ◻ Banana
│ ◻ Cherry
│ ◻ Grape
│ ◻ Orange
│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=11>",
"<cursor.down count=1>",
"<erase.down>",
"◆ Select a fruit

│ Search: _
│ ◼ Apple
│ ◻ Banana
│ ◻ Cherry
│ ◻ Grape
│ ◻ Orange
│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search
└",
"<cursor.backward count=999><cursor.up count=10>",
"<cursor.down count=1>",
"<erase.down>",
"◇ Select a fruit
│ 1 items selected",
"
",
"<cursor.show>",
]
`;
47 changes: 42 additions & 5 deletions packages/prompts/test/autocomplete.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { autocomplete } from '../src/autocomplete.js';
import { autocomplete, autocompleteMultiselect } from '../src/autocomplete.js';
import { MockReadable, MockWritable } from './test-utils.js';

describe('autocomplete', () => {
Expand Down Expand Up @@ -30,9 +30,9 @@ describe('autocomplete', () => {
output,
});

expect(output.buffer).toMatchSnapshot();
input.emit('keypress', '', { name: 'return' });
await result;
expect(output.buffer).toMatchSnapshot();
});

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

expect(output.buffer).toMatchSnapshot();
input.emit('keypress', '', { name: 'return' });
await result;
expect(output.buffer).toMatchSnapshot();
});

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

// Type something that won't match
input.emit('keypress', 'z', { name: 'z' });
expect(output.buffer).toMatchSnapshot();
input.emit('keypress', '', { name: 'return' });
await result;
expect(output.buffer).toMatchSnapshot();
});

test('shows hint when option has hint and is focused', async () => {
Expand All @@ -83,9 +83,9 @@ describe('autocomplete', () => {
input.emit('keypress', '', { name: 'down' });
input.emit('keypress', '', { name: 'down' });
input.emit('keypress', '', { name: 'down' });
expect(output.buffer).toMatchSnapshot();
input.emit('keypress', '', { name: 'return' });
await result;
expect(output.buffer).toMatchSnapshot();
});

test('shows selected value in submit state', async () => {
Expand Down Expand Up @@ -121,3 +121,40 @@ describe('autocomplete', () => {
expect(output.buffer).toMatchSnapshot();
});
});

describe('autocompleteMultiselect', () => {
let input: MockReadable;
let output: MockWritable;
const testOptions = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'grape', label: 'Grape' },
{ value: 'orange', label: 'Orange' },
];

beforeEach(() => {
input = new MockReadable();
output = new MockWritable();
});

afterEach(() => {
vi.restoreAllMocks();
});

test('renders error when empty selection & required is true', async () => {
const result = autocompleteMultiselect({
message: 'Select a fruit',
options: testOptions,
required: true,
input,
output,
});

input.emit('keypress', '', { name: 'return' });
input.emit('keypress', '', { name: 'tab' });
input.emit('keypress', '', { name: 'return' });
await result;
expect(output.buffer).toMatchSnapshot();
});
});