diff --git a/.changeset/tame-beds-kiss.md b/.changeset/tame-beds-kiss.md new file mode 100644 index 00000000..8a2808fd --- /dev/null +++ b/.changeset/tame-beds-kiss.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": minor +--- + +Support scrolling and `maxItems` option for `groupMultiselect`, and removes indent when `withGuide` is set to `false` diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index 99c4dbf6..80445453 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -9,12 +9,14 @@ import { S_CHECKBOX_SELECTED, symbol, } from './common.js'; +import { limitOptions } from './limit-options.js'; import type { Option } from './select.js'; export interface GroupMultiSelectOptions extends CommonOptions { message: string; options: Record[]>; initialValues?: Value[]; + maxItems?: number; required?: boolean; cursorAt?: Value; selectableGroups?: boolean; @@ -42,8 +44,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : ''; let spacingPrefix = ''; if (groupSpacing > 0 && !isItem) { - const spacingPrefixText = `\n${styleText('cyan', S_BAR)}`; - spacingPrefix = `${spacingPrefixText.repeat(groupSpacing - 1)}${spacingPrefixText} `; + spacingPrefix = '\n'.repeat(groupSpacing); } if (state === 'active') { @@ -108,6 +109,30 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const title = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} ${opts.message}\n`; const value = this.value ?? []; + const styleOption = ( + option: Option & { group: string | boolean }, + active: boolean + ) => { + const options = this.options; + const selected = + value.includes(option.value) || + (option.group === true && this.isGroupSelected(`${option.value}`)); + const groupActive = + !active && + typeof option.group === 'string' && + this.options[this.cursor].value === option.group; + if (groupActive) { + return opt(option, selected ? 'group-active-selected' : 'group-active', options); + } + if (active && selected) { + return opt(option, 'active-selected', options); + } + if (selected) { + return opt(option, 'selected', options); + } + return opt(option, active ? 'active' : 'inactive', options); + }; + switch (this.state) { case 'submit': { const selectedOptions = this.options @@ -127,6 +152,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => }`; } case 'error': { + const guidePrefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : ''; const footer = this.error .split('\n') .map((ln, i) => @@ -135,60 +161,35 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => : ` ${ln}` ) .join('\n'); - return `${title}${hasGuide ? `${styleText('yellow', S_BAR)} ` : ''}${this.options - .map((option, i, options) => { - const selected = - value.includes(option.value) || - (option.group === true && this.isGroupSelected(`${option.value}`)); - const active = i === this.cursor; - const groupActive = - !active && - typeof option.group === 'string' && - this.options[this.cursor].value === option.group; - if (groupActive) { - return opt(option, selected ? 'group-active-selected' : 'group-active', options); - } - if (active && selected) { - return opt(option, 'active-selected', options); - } - if (selected) { - return opt(option, 'selected', options); - } - return opt(option, active ? 'active' : 'inactive', options); - }) - .join(`\n${hasGuide ? `${styleText('yellow', S_BAR)} ` : ''}`)}\n${footer}\n`; + // Calculate rowPadding: title lines + footer lines (error message + trailing newline) + const titleLineCount = title.split('\n').length; + const footerLineCount = footer.split('\n').length + 1; // footer + trailing newline + const optionsText = limitOptions({ + output: opts.output, + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + columnPadding: guidePrefix.length, + rowPadding: titleLineCount + footerLineCount, + style: styleOption, + }).join(`\n${guidePrefix}`); + return `${title}${guidePrefix}${optionsText}\n${footer}\n`; } default: { - const optionsText = this.options - .map((option, i, options) => { - const selected = - value.includes(option.value) || - (option.group === true && this.isGroupSelected(`${option.value}`)); - const active = i === this.cursor; - const groupActive = - !active && - typeof option.group === 'string' && - this.options[this.cursor].value === option.group; - let optionText = ''; - if (groupActive) { - optionText = opt( - option, - selected ? 'group-active-selected' : 'group-active', - options - ); - } else if (active && selected) { - optionText = opt(option, 'active-selected', options); - } else if (selected) { - optionText = opt(option, 'selected', options); - } else { - optionText = opt(option, active ? 'active' : 'inactive', options); - } - const prefix = i !== 0 && !optionText.startsWith('\n') ? ' ' : ''; - return `${prefix}${optionText}`; - }) - .join(`\n${hasGuide ? styleText('cyan', S_BAR) : ''}`); - const optionsPrefix = optionsText.startsWith('\n') ? '' : ' '; - return `${title}${hasGuide ? styleText('cyan', S_BAR) : ''}${optionsPrefix}${optionsText}\n${ + const guidePrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; + // Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline) + const titleLineCount = title.split('\n').length; + const footerLineCount = (hasGuide ? 1 : 0) + 1; // guide line + trailing newline + const optionsText = limitOptions({ + output: opts.output, + options: this.options, + cursor: this.cursor, + maxItems: opts.maxItems, + columnPadding: guidePrefix.length, + rowPadding: titleLineCount + footerLineCount, + style: styleOption, + }).join(`\n${guidePrefix}`); + return `${title}${guidePrefix}${optionsText}\n${ hasGuide ? styleText('cyan', S_BAR_END) : '' }\n`; } diff --git a/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap b/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap index f439de6e..d6537c17 100644 --- a/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/group-multi-select.test.ts.snap @@ -252,23 +252,23 @@ exports[`groupMultiselect (isCI = false) > global withGuide: false removes guide [ "", "◆ foo - ◻ group1 - │ ◻ group1value0 - └ ◻ group1value1 +◻ group1 +│ ◻ group1value0 +└ ◻ group1value1 ", "", "", "", - " ◻ group1 - │ ◻ group1value0 - └ ◻ group1value1 + "◻ group1 +│ ◻ group1value0 +└ ◻ group1value1 ", "", "", "", - " │ ◼ group1value0", + "│ ◼ group1value0", "", "", "", @@ -325,12 +325,12 @@ exports[`groupMultiselect (isCI = false) > groupSpacing > renders spaced groups "", "│ ◆ foo -│ -│ +│ +│ │ ◻ group1 │ └ ◻ group1value0 -│ -│ +│ +│ │ ◻ group2 │ └ ◻ group2value0 └ @@ -340,8 +340,8 @@ exports[`groupMultiselect (isCI = false) > groupSpacing > renders spaced groups "", "│ ◻ group1 │ └ ◻ group1value0 -│ -│ +│ +│ │ ◻ group2 │ └ ◻ group2value0 └ @@ -351,8 +351,8 @@ exports[`groupMultiselect (isCI = false) > groupSpacing > renders spaced groups "", "│ ◼ group1 │ └ ◼ group1value0 -│ -│ +│ +│ │ ◻ group2 │ └ ◻ group2value0 └ @@ -389,6 +389,96 @@ exports[`groupMultiselect (isCI = false) > initial values can be set 1`] = ` ] `; +exports[`groupMultiselect (isCI = false) > maxItems renders a sliding window 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ... +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ◻ group2 +│ ... +└ +", + "", + "", + "", + "│ └ ◼ group1value5", + "", + "", + "", + "", + "◇ foo +│ group1value5", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = false) > renders error when nothing selected 1`] = ` [ "", @@ -536,6 +626,223 @@ exports[`groupMultiselect (isCI = false) > selectableGroups = false > selecting ] `; +exports[`groupMultiselect (isCI = false) > sliding window loops downwards 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ... +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ◻ group2 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ◻ group2 +│ │ ◻ group2value0 +│ ... +└ +", + "", + "", + "", + "│ └ ◻ group1value5 +│ ◻ group2 +│ │ ◻ group2value0 +│ │ ◻ group2value1 +│ ... +└ +", + "", + "", + "", + "│ ◻ group2 +│ │ ◻ group2value0 +│ │ ◻ group2value1 +│ │ ◻ group2value2 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group2value0 +│ │ ◻ group2value1 +│ │ ◻ group2value2 +│ │ ◻ group2value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group2value1 +│ │ ◻ group2value2 +│ │ ◻ group2value3 +│ │ ◻ group2value4 +│ └ ◻ group2value5 +└ +", + "", + "", + "", + "│ │ ◻ group2value3 +│ │ ◻ group2value4 +│ └ ◻ group2value5 +└ +", + "", + "", + "", + "│ │ ◻ group2value4 +│ └ ◻ group2value5 +└ +", + "", + "", + "", + "│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◼ group1value0", + "", + "", + "", + "", + "◇ foo +│ group1value0", + " +", + "", +] +`; + +exports[`groupMultiselect (isCI = false) > sliding window loops upwards 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ... +│ │ ◻ group2value1 +│ │ ◻ group2value2 +│ │ ◻ group2value3 +│ │ ◻ group2value4 +│ └ ◻ group2value5 +└ +", + "", + "", + "", + "│ └ ◼ group2value5", + "", + "", + "", + "", + "◇ foo +│ group2value5", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = false) > values can be non-primitive 1`] = ` [ "", @@ -574,23 +881,23 @@ exports[`groupMultiselect (isCI = false) > withGuide: false removes guide 1`] = [ "", "◆ foo - ◻ group1 - │ ◻ group1value0 - └ ◻ group1value1 +◻ group1 +│ ◻ group1value0 +└ ◻ group1value1 ", "", "", "", - " ◻ group1 - │ ◻ group1value0 - └ ◻ group1value1 + "◻ group1 +│ ◻ group1value0 +└ ◻ group1value1 ", "", "", "", - " │ ◼ group1value0", + "│ ◼ group1value0", "", "", "", @@ -854,23 +1161,23 @@ exports[`groupMultiselect (isCI = true) > global withGuide: false removes guide [ "", "◆ foo - ◻ group1 - │ ◻ group1value0 - └ ◻ group1value1 +◻ group1 +│ ◻ group1value0 +└ ◻ group1value1 ", "", "", "", - " ◻ group1 - │ ◻ group1value0 - └ ◻ group1value1 + "◻ group1 +│ ◻ group1value0 +└ ◻ group1value1 ", "", "", "", - " │ ◼ group1value0", + "│ ◼ group1value0", "", "", "", @@ -927,12 +1234,12 @@ exports[`groupMultiselect (isCI = true) > groupSpacing > renders spaced groups 1 "", "│ ◆ foo -│ -│ +│ +│ │ ◻ group1 │ └ ◻ group1value0 -│ -│ +│ +│ │ ◻ group2 │ └ ◻ group2value0 └ @@ -942,8 +1249,8 @@ exports[`groupMultiselect (isCI = true) > groupSpacing > renders spaced groups 1 "", "│ ◻ group1 │ └ ◻ group1value0 -│ -│ +│ +│ │ ◻ group2 │ └ ◻ group2value0 └ @@ -953,8 +1260,8 @@ exports[`groupMultiselect (isCI = true) > groupSpacing > renders spaced groups 1 "", "│ ◼ group1 │ └ ◼ group1value0 -│ -│ +│ +│ │ ◻ group2 │ └ ◻ group2value0 └ @@ -991,6 +1298,96 @@ exports[`groupMultiselect (isCI = true) > initial values can be set 1`] = ` ] `; +exports[`groupMultiselect (isCI = true) > maxItems renders a sliding window 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ... +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ◻ group2 +│ ... +└ +", + "", + "", + "", + "│ └ ◼ group1value5", + "", + "", + "", + "", + "◇ foo +│ group1value5", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = true) > renders error when nothing selected 1`] = ` [ "", @@ -1138,6 +1535,223 @@ exports[`groupMultiselect (isCI = true) > selectableGroups = false > selecting a ] `; +exports[`groupMultiselect (isCI = true) > sliding window loops downwards 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ... +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value3 +│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ◻ group2 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group1value4 +│ └ ◻ group1value5 +│ ◻ group2 +│ │ ◻ group2value0 +│ ... +└ +", + "", + "", + "", + "│ └ ◻ group1value5 +│ ◻ group2 +│ │ ◻ group2value0 +│ │ ◻ group2value1 +│ ... +└ +", + "", + "", + "", + "│ ◻ group2 +│ │ ◻ group2value0 +│ │ ◻ group2value1 +│ │ ◻ group2value2 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group2value0 +│ │ ◻ group2value1 +│ │ ◻ group2value2 +│ │ ◻ group2value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◻ group2value1 +│ │ ◻ group2value2 +│ │ ◻ group2value3 +│ │ ◻ group2value4 +│ └ ◻ group2value5 +└ +", + "", + "", + "", + "│ │ ◻ group2value3 +│ │ ◻ group2value4 +│ └ ◻ group2value5 +└ +", + "", + "", + "", + "│ │ ◻ group2value4 +│ └ ◻ group2value5 +└ +", + "", + "", + "", + "│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ │ ◼ group1value0", + "", + "", + "", + "", + "◇ foo +│ group1value0", + " +", + "", +] +`; + +exports[`groupMultiselect (isCI = true) > sliding window loops upwards 1`] = ` +[ + "", + "│ +◆ foo +│ ◻ group1 +│ │ ◻ group1value0 +│ │ ◻ group1value1 +│ │ ◻ group1value2 +│ │ ◻ group1value3 +│ ... +└ +", + "", + "", + "", + "│ ... +│ │ ◻ group2value1 +│ │ ◻ group2value2 +│ │ ◻ group2value3 +│ │ ◻ group2value4 +│ └ ◻ group2value5 +└ +", + "", + "", + "", + "│ └ ◼ group2value5", + "", + "", + "", + "", + "◇ foo +│ group2value5", + " +", + "", +] +`; + exports[`groupMultiselect (isCI = true) > values can be non-primitive 1`] = ` [ "", @@ -1176,23 +1790,23 @@ exports[`groupMultiselect (isCI = true) > withGuide: false removes guide 1`] = ` [ "", "◆ foo - ◻ group1 - │ ◻ group1value0 - └ ◻ group1value1 +◻ group1 +│ ◻ group1value0 +└ ◻ group1value1 ", "", "", "", - " ◻ group1 - │ ◻ group1value0 - └ ◻ group1value1 + "◻ group1 +│ ◻ group1value0 +└ ◻ group1value1 ", "", "", "", - " │ ◼ group1value0", + "│ ◼ group1value0", "", "", "", diff --git a/packages/prompts/test/group-multi-select.test.ts b/packages/prompts/test/group-multi-select.test.ts index 7d24dc18..33769ebf 100644 --- a/packages/prompts/test/group-multi-select.test.ts +++ b/packages/prompts/test/group-multi-select.test.ts @@ -306,6 +306,76 @@ describe.each(['true', 'false'])('groupMultiselect (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + test('maxItems renders a sliding window', async () => { + const result = prompts.groupMultiselect({ + message: 'foo', + input, + output, + options: { + group1: [...Array(6).keys()].map((k) => ({ value: `group1value${k}` })), + group2: [...Array(6).keys()].map((k) => ({ value: `group2value${k}` })), + }, + maxItems: 6, + }); + + for (let i = 0; i < 6; i++) { + input.emit('keypress', '', { name: 'down' }); + } + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['group1value5']); + expect(output.buffer).toMatchSnapshot(); + }); + + test('sliding window loops upwards', async () => { + const result = prompts.groupMultiselect({ + message: 'foo', + input, + output, + options: { + group1: [...Array(6).keys()].map((k) => ({ value: `group1value${k}` })), + group2: [...Array(6).keys()].map((k) => ({ value: `group2value${k}` })), + }, + maxItems: 6, + }); + + input.emit('keypress', '', { name: 'up' }); + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['group2value5']); + expect(output.buffer).toMatchSnapshot(); + }); + + test('sliding window loops downwards', async () => { + const result = prompts.groupMultiselect({ + message: 'foo', + input, + output, + options: { + group1: [...Array(6).keys()].map((k) => ({ value: `group1value${k}` })), + group2: [...Array(6).keys()].map((k) => ({ value: `group2value${k}` })), + }, + maxItems: 6, + }); + + for (let i = 0; i < 15; i++) { + input.emit('keypress', '', { name: 'down' }); + } + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toEqual(['group1value0']); + expect(output.buffer).toMatchSnapshot(); + }); + describe('groupSpacing', () => { test('renders spaced groups', async () => { const result = prompts.groupMultiselect({