diff --git a/.changeset/strong-ravens-greet.md b/.changeset/strong-ravens-greet.md new file mode 100644 index 0000000..23d6677 --- /dev/null +++ b/.changeset/strong-ravens-greet.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": minor +--- + +Add a `required` option to autocomplete multiselect. diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 59e5204..ee84b9a 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -41,7 +41,7 @@ function getSelectedOptions(values: T[], options: Option[]): Option[] { return results; } -export interface AutocompleteOptions extends CommonOptions { +interface AutocompleteSharedOptions extends CommonOptions { /** * The message to display to the user. */ @@ -50,10 +50,6 @@ export interface AutocompleteOptions extends CommonOptions { * Available options for the autocomplete prompt. */ options: Option[]; - /** - * The initial selected value. - */ - initialValue?: Value; /** * Maximum number of items to display at once. */ @@ -64,6 +60,13 @@ export interface AutocompleteOptions extends CommonOptions { placeholder?: string; } +export interface AutocompleteOptions extends AutocompleteSharedOptions { + /** + * The initial selected value. + */ + initialValue?: Value; +} + export const autocomplete = (opts: AutocompleteOptions) => { const prompt = new AutocompletePrompt({ options: opts.options, @@ -158,35 +161,15 @@ export const autocomplete = (opts: AutocompleteOptions) => { }; // Type definition for the autocompleteMultiselect component -export interface AutocompleteMultiSelectOptions { - /** - * The message to display to the user - */ - message: string; - /** - * The options for the user to choose from - */ - options: Option[]; +export interface AutocompleteMultiSelectOptions extends AutocompleteSharedOptions { /** * 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; } /** @@ -220,6 +203,12 @@ export const autocompleteMultiselect = (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, @@ -229,10 +218,6 @@ export const autocompleteMultiselect = (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 @@ -270,6 +255,9 @@ export const autocompleteMultiselect = (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, @@ -285,6 +273,7 @@ export const autocompleteMultiselect = (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)}`, diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index c74f7d1..b13641b 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -15,6 +15,14 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` │ ... │ ↑/↓ to select • Enter: confirm • Type: to search └", + "", + "", + "", + "◇ Select an option +│ Option 0", + " +", + "", ] `; @@ -32,6 +40,14 @@ exports[`autocomplete > renders initial UI with message and instructions 1`] = ` │ ○ Orange │ ↑/↓ to select • Enter: confirm • Type: to search └", + "", + "", + "", + "◇ Select a fruit +│ Apple", + " +", + "", ] `; @@ -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 └", + "", + "", + "", + "◇ Select a fruit +│ Kiwi", + " +", + "", ] `; @@ -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 └", + "", + "", + "", + "◇ Select a fruit +│ Apple", + " +", + "", ] `; @@ -208,3 +240,55 @@ exports[`autocomplete > supports initialValue 1`] = ` "", ] `; + +exports[`autocompleteMultiselect > renders error when empty selection & required is true 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "▲ Select a fruit + +│ Search: _ +│ Please select at least one item +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◆ Select a fruit + +│ Search: _ +│ ◼ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ 1 items selected", + " +", + "", +] +`; diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index d2edd62..2867e67 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -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', () => { @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -136,3 +136,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(); + }); +});