[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

feat: add `required` option to autocomplete multiselect (#329)

authored by

James Garbutt and committed by
GitHub
(May 26, 2025, 5:26 PM +0100) 9bd80725 8ead5d39

+152 -37
+5
.changeset/strong-ravens-greet.md
··· 1 + --- 2 + "@clack/prompts": minor 3 + --- 4 + 5 + Add a `required` option to autocomplete multiselect.
+21 -32
packages/prompts/src/autocomplete.ts
··· 41 41 return results; 42 42 } 43 43 44 - export interface AutocompleteOptions<Value> extends CommonOptions { 44 + interface AutocompleteSharedOptions<Value> extends CommonOptions { 45 45 /** 46 46 * The message to display to the user. 47 47 */ ··· 51 51 */ 52 52 options: Option<Value>[]; 53 53 /** 54 - * The initial selected value. 55 - */ 56 - initialValue?: Value; 57 - /** 58 54 * Maximum number of items to display at once. 59 55 */ 60 56 maxItems?: number; ··· 62 58 * Placeholder text to display when no input is provided. 63 59 */ 64 60 placeholder?: string; 61 + } 62 + 63 + export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Value> { 64 + /** 65 + * The initial selected value. 66 + */ 67 + initialValue?: Value; 65 68 } 66 69 67 70 export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => { ··· 158 161 }; 159 162 160 163 // 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> { 170 165 /** 171 166 * The initial selected values 172 167 */ 173 168 initialValues?: Value[]; 174 169 /** 175 - * The maximum number of items that can be selected 170 + * If true, at least one option must be selected 176 171 */ 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 188 - */ 189 - output?: NodeJS.WriteStream; 172 + required?: boolean; 190 173 } 191 174 192 175 /** ··· 220 203 filter: (search, opt) => { 221 204 return getFilteredOption(search, opt); 222 205 }, 206 + validate: () => { 207 + if (opts.required && prompt.selectedValues.length === 0) { 208 + return 'Please select at least one item'; 209 + } 210 + return undefined; 211 + }, 223 212 placeholder: opts.placeholder, 224 213 initialValue: opts.initialValues, 225 214 input: opts.input, ··· 229 218 const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 230 219 231 220 // Selection counter 232 - const counter = 233 - this.selectedValues.length > 0 234 - ? color.cyan(` (${this.selectedValues.length} selected)`) 235 - : ''; 236 221 const value = String(this.value ?? ''); 237 222 238 223 // Search input display ··· 270 255 ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] 271 256 : []; 272 257 258 + const errorMessage = 259 + this.state === 'error' ? [`${color.cyan(S_BAR)} ${color.yellow(this.error)}`] : []; 260 + 273 261 // Get limited options for display 274 262 const displayOptions = limitOptions({ 275 263 cursor: this.cursor, ··· 285 273 title, 286 274 `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, 287 275 ...noResults, 276 + ...errorMessage, 288 277 ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), 289 278 `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, 290 279 `${color.cyan(S_BAR_END)}`,
+84
packages/prompts/test/__snapshots__/autocomplete.test.ts.snap
··· 15 15 │ ... 16 16 │ ↑/↓ to select • Enter: confirm • Type: to search 17 17 └", 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>", 18 26 ] 19 27 `; 20 28 ··· 32 40 │ ○ Orange 33 41 │ ↑/↓ to select • Enter: confirm • Type: to search 34 42 └", 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>", 35 51 ] 36 52 `; 37 53 ··· 96 112 │ ● Kiwi (New Zealand) 97 113 │ ↑/↓ to select • Enter: confirm • Type: to search 98 114 └", 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>", 99 123 ] 100 124 `; 101 125 ··· 120 144 │ No matches found 121 145 │ ↑/↓ to select • Enter: confirm • Type: to search 122 146 └", 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>", 123 155 ] 124 156 `; 125 157 ··· 208 240 "<cursor.show>", 209 241 ] 210 242 `; 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 + `;
+42 -5
packages/prompts/test/autocomplete.test.ts
··· 1 1 import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 2 - import { autocomplete } from '../src/autocomplete.js'; 2 + import { autocomplete, autocompleteMultiselect } from '../src/autocomplete.js'; 3 3 import { MockReadable, MockWritable } from './test-utils.js'; 4 4 5 5 describe('autocomplete', () => { ··· 30 30 output, 31 31 }); 32 32 33 - expect(output.buffer).toMatchSnapshot(); 34 33 input.emit('keypress', '', { name: 'return' }); 35 34 await result; 35 + expect(output.buffer).toMatchSnapshot(); 36 36 }); 37 37 38 38 test('limits displayed options when maxItems is set', async () => { ··· 49 49 output, 50 50 }); 51 51 52 - expect(output.buffer).toMatchSnapshot(); 53 52 input.emit('keypress', '', { name: 'return' }); 54 53 await result; 54 + expect(output.buffer).toMatchSnapshot(); 55 55 }); 56 56 57 57 test('shows no matches message when search has no results', async () => { ··· 64 64 65 65 // Type something that won't match 66 66 input.emit('keypress', 'z', { name: 'z' }); 67 - expect(output.buffer).toMatchSnapshot(); 68 67 input.emit('keypress', '', { name: 'return' }); 69 68 await result; 69 + expect(output.buffer).toMatchSnapshot(); 70 70 }); 71 71 72 72 test('shows hint when option has hint and is focused', async () => { ··· 83 83 input.emit('keypress', '', { name: 'down' }); 84 84 input.emit('keypress', '', { name: 'down' }); 85 85 input.emit('keypress', '', { name: 'down' }); 86 - expect(output.buffer).toMatchSnapshot(); 87 86 input.emit('keypress', '', { name: 'return' }); 88 87 await result; 88 + expect(output.buffer).toMatchSnapshot(); 89 89 }); 90 90 91 91 test('shows selected value in submit state', async () => { ··· 136 136 expect(output.buffer).toMatchSnapshot(); 137 137 }); 138 138 }); 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 + });