[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.

ref: multiselect (#65)

authored by

Nate Moore and committed by
GitHub
(Feb 20, 2023, 7:48 PM -0600) 74ced6fc c2dbd27c

+55 -61
+8
.changeset/tough-plums-greet.md
··· 1 + --- 2 + "@clack/core": patch 3 + "@clack/prompts": patch 4 + --- 5 + 6 + Don't mutate `initialValue` in `multiselect`, fix parameter type for `validate()`. 7 + 8 + Credits to @banjo for the bug report and initial PR!
+12 -33
packages/core/src/prompts/multi-select.ts
··· 1 - import color from 'picocolors'; 2 1 import Prompt, { PromptOptions } from './prompt'; 3 2 4 3 interface MultiSelectOptions<T extends { value: any }> extends PromptOptions<MultiSelectPrompt<T>> { ··· 10 9 export default class MultiSelectPrompt<T extends { value: any }> extends Prompt { 11 10 options: T[]; 12 11 cursor: number = 0; 13 - selectedValues: T[]; 14 12 15 13 private get _value() { 16 14 return this.options[this.cursor]; 17 15 } 18 16 19 - private changeValue() { 20 - const isValueAlreadySelected = this.selectedValues.some((value) => value === this._value.value); 21 - if (isValueAlreadySelected) { 22 - this.selectedValues = this.selectedValues.filter((value) => value !== this._value.value); 23 - } else { 24 - this.selectedValues.push(this._value.value); 25 - } 17 + private toggleValue() { 18 + const selected = this.value.some(({ value }: T) => value === this._value.value); 19 + this.value = selected 20 + ? this.value.filter(({ value }: T) => value !== this._value.value) 21 + : [...this.value, this._value]; 26 22 } 27 23 28 24 constructor(opts: MultiSelectOptions<T>) { 29 - if (!opts.validate) { 30 - opts.validate = () => { 31 - if (opts.required && this.selectedValues.length === 0) 32 - return `Please select at least one option\n${color.reset( 33 - color.dim( 34 - `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( 35 - color.bgWhite(color.inverse(' enter ')) 36 - )} to submit` 37 - ) 38 - )}`; 39 - }; 40 - } 41 25 super(opts, false); 42 - this.once('finalize', () => { 43 - this.value = this.selectedValues; 44 - }); 26 + 45 27 this.options = opts.options; 46 - this.cursor = this.options.findIndex(({ value }) => value === opts.cursorAt); 47 - this.selectedValues = opts.initialValue || []; 48 - if (this.cursor === -1) this.cursor = 0; 28 + this.value = this.options.filter(({ value }) => opts.initialValue?.includes(value)); 29 + this.cursor = Math.max( 30 + this.options.findIndex(({ value }) => value === opts.cursorAt), 31 + 0 32 + ); 49 33 50 34 this.on('cursor', (key) => { 51 35 switch (key) { ··· 58 42 this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; 59 43 break; 60 44 case 'space': 61 - this.changeValue(); 62 - break; 63 - case 'enter': 64 - case 'return': 65 - this.state = 'submit'; 66 - this.close(); 45 + this.toggleValue(); 67 46 break; 68 47 } 69 48 });
+35 -28
packages/prompts/src/index.ts
··· 139 139 initialValue?: Options[number]['value']; 140 140 } 141 141 142 - export interface MultiSelectOptions<Options extends Option<Value>[], Value extends Primitive> { 143 - message: string; 144 - options: Options; 145 - initialValue?: Options[number]['value'][]; 146 - required?: boolean; 147 - cursorAt?: Options[number]['value']; 148 - } 149 - 150 142 export const select = <Options extends Option<Value>[], Value extends Primitive>( 151 143 opts: SelectOptions<Options, Value> 152 144 ) => { ··· 191 183 }).prompt() as Promise<Options[number]['value'] | symbol>; 192 184 }; 193 185 186 + export interface MultiSelectOptions<Options extends Option<Value>[], Value extends Primitive> { 187 + message: string; 188 + options: Options; 189 + initialValue?: Options[number]['value'][]; 190 + required?: boolean; 191 + cursorAt?: Options[number]['value']; 192 + } 194 193 export const multiselect = <Options extends Option<Value>[], Value extends Primitive>( 195 194 opts: MultiSelectOptions<Options, Value> 196 195 ) => { ··· 222 221 initialValue: opts.initialValue, 223 222 required: opts.required ?? true, 224 223 cursorAt: opts.cursorAt, 224 + validate(value) { 225 + if (this.required && value.length === 0) 226 + return `Please select at least one option.\n${color.reset( 227 + color.dim( 228 + `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( 229 + color.bgWhite(color.inverse(' enter ')) 230 + )} to submit` 231 + ) 232 + )}`; 233 + }, 225 234 render() { 226 235 let title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 227 236 228 237 switch (this.state) { 229 238 case 'submit': { 230 - const selectedOptions = this.options.filter((option) => 231 - this.selectedValues.some((selectedValue) => selectedValue === (option.value as any)) 232 - ); 233 - return `${title}${color.gray(S_BAR)} ${selectedOptions 234 - .map((option, i) => opt(option, 'submitted')) 239 + return `${title}${color.gray(S_BAR)} ${this.value 240 + .map((option: Option<Value>) => opt(option, 'submitted')) 235 241 .join(color.dim(', '))}`; 236 242 } 237 243 case 'cancel': { 238 - const selectedOptions = this.options.filter((option) => 239 - this.selectedValues.some((selectedValue) => selectedValue === (option.value as any)) 240 - ); 241 - const label = selectedOptions 242 - .map((option, i) => opt(option, 'cancelled')) 244 + const label = this.value 245 + .map((option: Option<Value>) => opt(option, 'cancelled')) 243 246 .join(color.dim(', ')); 244 247 return `${title}${color.gray(S_BAR)} ${ 245 248 label.trim() ? `${label}\n${color.gray(S_BAR)}` : '' ··· 254 257 .join('\n'); 255 258 return `${title}${color.yellow(S_BAR)} ${this.options 256 259 .map((option, i) => { 257 - const isOptionSelected = this.selectedValues.includes(option.value as any); 258 - const isOptionHovered = i === this.cursor; 259 - if (isOptionHovered && isOptionSelected) { 260 + const selected = this.value.some( 261 + ({ value }: Option<Value>) => value === option.value 262 + ); 263 + const active = i === this.cursor; 264 + if (active && selected) { 260 265 return opt(option, 'active-selected'); 261 266 } 262 - if (isOptionSelected) { 267 + if (selected) { 263 268 return opt(option, 'selected'); 264 269 } 265 - return opt(option, isOptionHovered ? 'active' : 'inactive'); 270 + return opt(option, active ? 'active' : 'inactive'); 266 271 }) 267 272 .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; 268 273 } 269 274 default: { 270 275 return `${title}${color.cyan(S_BAR)} ${this.options 271 276 .map((option, i) => { 272 - const isOptionSelected = this.selectedValues.includes(option.value as any); 273 - const isOptionHovered = i === this.cursor; 274 - if (isOptionHovered && isOptionSelected) { 277 + const selected = this.value.some( 278 + ({ value }: Option<Value>) => value === option.value 279 + ); 280 + const active = i === this.cursor; 281 + if (active && selected) { 275 282 return opt(option, 'active-selected'); 276 283 } 277 - if (isOptionSelected) { 284 + if (selected) { 278 285 return opt(option, 'selected'); 279 286 } 280 - return opt(option, isOptionHovered ? 'active' : 'inactive'); 287 + return opt(option, active ? 'active' : 'inactive'); 281 288 }) 282 289 .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; 283 290 }