From b5c6b9b4cccef2c652409576bd260ba2d0ef4693 Mon Sep 17 00:00:00 2001 From: Bruno Rocha Date: Sat, 26 Aug 2023 12:50:59 -0300 Subject: [PATCH 1/2] feat(@clack/promtps): multiselect maxItems --- .changeset/bright-rules-yell.md | 5 ++ packages/prompts/src/index.ts | 145 +++++++++++++++++--------------- 2 files changed, 81 insertions(+), 69 deletions(-) create mode 100644 .changeset/bright-rules-yell.md diff --git a/.changeset/bright-rules-yell.md b/.changeset/bright-rules-yell.md new file mode 100644 index 00000000..bca98050 --- /dev/null +++ b/.changeset/bright-rules-yell.md @@ -0,0 +1,5 @@ +--- +'@clack/prompts': patch +--- + +Feat multiselect maxItems option diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 42451cc8..146a4b68 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -58,6 +58,41 @@ const symbol = (state: State) => { } }; +interface LimitOptionsParams { + options: TOption[]; + maxItems: number | undefined; + cursor: number; + style: (option: TOption, active: boolean) => string; +} + +const limitOptions = (params: LimitOptionsParams): string[] => { + const { cursor, options, style } = params; + + // We clamp to minimum 5 because anything less doesn't make sense UX wise + const maxItems = params.maxItems === undefined ? Infinity : Math.max(params.maxItems, 5); + let slidingWindowLocation = 0; + + if (cursor >= slidingWindowLocation + maxItems - 3) { + slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0); + } else if (cursor < slidingWindowLocation + 2) { + slidingWindowLocation = Math.max(cursor - 2, 0); + } + + const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0; + const shouldRenderBottomEllipsis = + maxItems < options.length && slidingWindowLocation + maxItems < options.length; + + return options + .slice(slidingWindowLocation, slidingWindowLocation + maxItems) + .map((option, i, arr) => { + const isTopLimit = i === 0 && shouldRenderTopEllipsis; + const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis; + return isTopLimit || isBottomLimit + ? color.dim('...') + : style(option, i + slidingWindowLocation === cursor); + }); +}; + export interface TextOptions { message: string; placeholder?: string; @@ -184,20 +219,20 @@ export interface SelectOptions { export const select = (opts: SelectOptions) => { const opt = (option: Option, state: 'inactive' | 'active' | 'selected' | 'cancelled') => { const label = option.label ?? String(option.value); - if (state === 'active') { - return `${color.green(S_RADIO_ACTIVE)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' - }`; - } else if (state === 'selected') { - return `${color.dim(label)}`; - } else if (state === 'cancelled') { - return `${color.strikethrough(color.dim(label))}`; + switch (state) { + case 'selected': + return `${color.dim(label)}`; + case 'active': + return `${color.green(S_RADIO_ACTIVE)} ${label} ${ + option.hint ? color.dim(`(${option.hint})`) : '' + }`; + case 'cancelled': + return `${color.strikethrough(color.dim(label))}`; + default: + return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`; } - return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`; }; - let slidingWindowLocation = 0; - return new SelectPrompt({ options: opts.options, initialValue: opts.initialValue, @@ -213,38 +248,12 @@ export const select = (opts: SelectOptions) => { 'cancelled' )}\n${color.gray(S_BAR)}`; default: { - // We clamp to minimum 5 because anything less doesn't make sense UX wise - const maxItems = opts.maxItems === undefined ? Infinity : Math.max(opts.maxItems, 5); - if (this.cursor >= slidingWindowLocation + maxItems - 3) { - slidingWindowLocation = Math.max( - Math.min(this.cursor - maxItems + 3, this.options.length - maxItems), - 0 - ); - } else if (this.cursor < slidingWindowLocation + 2) { - slidingWindowLocation = Math.max(this.cursor - 2, 0); - } - - const shouldRenderTopEllipsis = - maxItems < this.options.length && slidingWindowLocation > 0; - const shouldRenderBottomEllipsis = - maxItems < this.options.length && - slidingWindowLocation + maxItems < this.options.length; - - return `${title}${color.cyan(S_BAR)} ${this.options - .slice(slidingWindowLocation, slidingWindowLocation + maxItems) - .map((option, i, arr) => { - if (i === 0 && shouldRenderTopEllipsis) { - return color.dim('...'); - } else if (i === arr.length - 1 && shouldRenderBottomEllipsis) { - return color.dim('...'); - } else { - return opt( - option, - i + slidingWindowLocation === this.cursor ? 'active' : 'inactive' - ); - } - }) - .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + return `${title}${color.cyan(S_BAR)} ${limitOptions({ + cursor: this.cursor, + options: this.options, + maxItems: opts.maxItems, + style: (item, active) => opt(item, active ? 'active' : 'inactive'), + }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } } }, @@ -301,6 +310,7 @@ export interface MultiSelectOptions { message: string; options: Option[]; initialValues?: Value[]; + maxItems?: number; required?: boolean; cursorAt?: Value; } @@ -346,6 +356,17 @@ export const multiselect = (opts: MultiSelectOptions) => { render() { let title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const styleOption = (option: Option, active: boolean) => { + const selected = this.value.includes(option.value); + if (active && selected) { + return opt(option, 'active-selected'); + } + if (selected) { + return opt(option, 'selected'); + } + return opt(option, active ? 'active' : 'inactive'); + }; + switch (this.state) { case 'submit': { return `${title}${color.gray(S_BAR)} ${ @@ -375,38 +396,24 @@ export const multiselect = (opts: MultiSelectOptions) => { title + color.yellow(S_BAR) + ' ' + - this.options - .map((option, i) => { - const selected = this.value.includes(option.value); - const active = i === this.cursor; - if (active && selected) { - return opt(option, 'active-selected'); - } - if (selected) { - return opt(option, 'selected'); - } - return opt(option, active ? 'active' : 'inactive'); - }) - .join(`\n${color.yellow(S_BAR)} `) + + limitOptions({ + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + style: styleOption, + }).join(`\n${color.yellow(S_BAR)} `) + '\n' + footer + '\n' ); } default: { - return `${title}${color.cyan(S_BAR)} ${this.options - .map((option, i) => { - const selected = this.value.includes(option.value); - const active = i === this.cursor; - if (active && selected) { - return opt(option, 'active-selected'); - } - if (selected) { - return opt(option, 'selected'); - } - return opt(option, active ? 'active' : 'inactive'); - }) - .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + return `${title}${color.cyan(S_BAR)} ${limitOptions({ + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + style: styleOption, + }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; } } }, From 3c739f060281cecb81fcfea5646e343696e9f2da Mon Sep 17 00:00:00 2001 From: Bruno Rocha Date: Mon, 28 Aug 2023 11:28:25 -0300 Subject: [PATCH 2/2] refactor: match changeset patch result with multiselect result --- examples/changesets/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/changesets/index.ts b/examples/changesets/index.ts index 90066191..40a53fcd 100644 --- a/examples/changesets/index.ts +++ b/examples/changesets/index.ts @@ -59,7 +59,7 @@ async function main() { (pkg) => !major.includes(pkg) && !minor.includes(pkg) ); if (possiblePackages.length === 0) return; - let note = possiblePackages.join('\n'); + let note = possiblePackages.join(color.dim(', ')); p.log.step(`These packages will have a ${color.green('patch')} bump.\n${color.dim(note)}`); return possiblePackages;