diff --git a/.changeset/happy-spoons-push.md b/.changeset/happy-spoons-push.md new file mode 100644 index 00000000..e524174f --- /dev/null +++ b/.changeset/happy-spoons-push.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +add inversion and selecting all options for autocomplete multiselect diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 6e308ff5..4b3e912d 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -143,6 +143,12 @@ export default class AutocompletePrompt extends Prompt< // Start navigation mode with up/down arrows if (isUpKey || isDownKey) { + // shift up/down behavior + if (key.shift) { + this.#handleShiftNavigation(isUpKey); + return; + } + this.#cursor = Math.max( 0, Math.min(this.#cursor + (isUpKey ? -1 : 1), this.filteredOptions.length - 1) @@ -173,6 +179,39 @@ export default class AutocompletePrompt extends Prompt< } } + #handleShiftNavigation(isUpKey: boolean) { + // invert if Shift + Down + if (!isUpKey) { + this.invertSelectedFiltered(); + return; + } + + // set to none if all are selected + if (this.selectedValues.length === this.filteredOptions.length) { + this.deselectAllFiltered(); + return; + } + + this.selectAllFiltered(); + return; + } + + selectAllFiltered() { + this.selectedValues = this.filteredOptions.map((opt) => opt.value); + } + + deselectAllFiltered() { + this.selectedValues = this.filteredOptions + .filter((opt) => !this.selectedValues.includes(opt.value)) + .map((opt) => opt.value); + } + + invertSelectedFiltered() { + this.selectedValues = this.filteredOptions + .filter((opt) => !this.selectedValues.includes(opt.value)) + .map((opt) => opt.value); + } + deselectAll() { this.selectedValues = []; } diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index a2041b2d..3c09df7e 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -275,6 +275,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // Instructions const instructions = [ `${color.dim('↑/↓')} to navigate`, + `${color.dim('Shift+↑/↓')} select all/inverse`, `${color.dim('Space:')} select`, `${color.dim('Enter:')} confirm`, `${color.dim('Type:')} to search`, diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index 1e0daf65..39304e29 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -286,6 +286,48 @@ exports[`autocomplete > supports initialValue 1`] = ` ] `; +exports[`autocompleteMultiselect > all selection only applies to filtered options 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: r█ (3 matches) +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◼ Cherry +│ ◼ Grape +│ ◼ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ 3 items selected", + " +", + "", +] +`; + exports[`autocompleteMultiselect > can be aborted by a signal 1`] = ` [ "", @@ -298,7 +340,7 @@ exports[`autocompleteMultiselect > can be aborted by a signal 1`] = ` │ ◻ Cherry │ ◻ Grape │ ◻ Orange -│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search └", " ", @@ -306,6 +348,193 @@ exports[`autocompleteMultiselect > can be aborted by a signal 1`] = ` ] `; +exports[`autocompleteMultiselect > everything can be selected with left arrow 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◼ Apple +│ ◼ Banana +│ ◼ Cherry +│ ◼ Grape +│ ◼ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ 5 items selected", + " +", + "", +] +`; + +exports[`autocompleteMultiselect > everything is deselected if left is pressed again 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◼ Apple +│ ◼ Banana +│ ◼ Cherry +│ ◼ Grape +│ ◼ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ 0 items selected", + " +", + "", +] +`; + +exports[`autocompleteMultiselect > inverse can be selected with right arrow 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search:  +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◼ Banana", + "", + "", + "", + "", + "│ ◼ Apple +│ ◻ Banana +│ ◼ Cherry +│ ◼ Grape +│ ◼ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ 4 items selected", + " +", + "", +] +`; + +exports[`autocompleteMultiselect > inversion only applies to filtered options 1`] = ` +[ + "", + "│ +◆ Select a fruit + +│ Search: _ +│ ◻ Apple +│ ◻ Banana +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: r█ (3 matches) +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ Search: r (3 matches) +│ ◻ Cherry +│ ◻ Grape +│ ◻ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "│ ◼ Grape", + "", + "", + "", + "", + "│ ◼ Cherry +│ ◻ Grape +│ ◼ Orange +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select a fruit +│ 2 items selected", + " +", + "", +] +`; + exports[`autocompleteMultiselect > renders error when empty selection & required is true 1`] = ` [ "", @@ -318,7 +547,7 @@ exports[`autocompleteMultiselect > renders error when empty selection & required │ ◻ Cherry │ ◻ Grape │ ◻ Orange -│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search └", "", "", @@ -332,7 +561,7 @@ exports[`autocompleteMultiselect > renders error when empty selection & required │ ◻ Cherry │ ◻ Grape │ ◻ Orange -│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search └", "", "", @@ -345,7 +574,7 @@ exports[`autocompleteMultiselect > renders error when empty selection & required │ ◻ Cherry │ ◻ Grape │ ◻ Orange -│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +│ ↑/↓ to navigate • Shift+↑/↓ select all/inverse • Space: select • Enter: confirm • Type: to search └", "", "", diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index 2e6f7b6c..9762c325 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -221,4 +221,86 @@ describe('autocompleteMultiselect', () => { expect(isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('everything can be selected with left arrow', async () => { + const result = autocompleteMultiselect({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + input.emit('keypress', '', { name: 'up', shift: true }); + input.emit('keypress', '', { name: 'return' }); + await result; + expect(output.buffer).toMatchSnapshot(); + expect(output.buffer.toString()).toMatch('5 items selected'); + }); + + test('everything is deselected if left is pressed again', async () => { + const result = autocompleteMultiselect({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + input.emit('keypress', '', { name: 'up', shift: true }); + input.emit('keypress', '', { name: 'up', shift: true }); + input.emit('keypress', '', { name: 'return' }); + await result; + expect(output.buffer).toMatchSnapshot(); + expect(output.buffer.toString()).toMatch('0 items selected'); + }); + + test('inverse can be selected with right arrow', async () => { + const result = autocompleteMultiselect({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'down', shift: true }); + input.emit('keypress', '', { name: 'return' }); + await result; + expect(output.buffer).toMatchSnapshot(); + expect(output.buffer.toString()).toMatch('4 items selected'); + }); + + test('all selection only applies to filtered options', async () => { + const result = autocompleteMultiselect({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + input.emit('keypress', 'r', { name: 'r' }); + input.emit('keypress', '', { name: 'up', shift: true }); + input.emit('keypress', '', { name: 'return' }); + await result; + expect(output.buffer).toMatchSnapshot(); + expect(output.buffer.toString()).toMatch('3 items selected'); + }); + + test('inversion only applies to filtered options', async () => { + const result = autocompleteMultiselect({ + message: 'Select a fruit', + options: testOptions, + input, + output, + }); + + input.emit('keypress', 'r', { name: 'r' }); + input.emit('keypress', '', { name: 'down' }); + input.emit('keypress', '', { name: 'space' }); + input.emit('keypress', '', { name: 'down', shift: true }); + input.emit('keypress', '', { name: 'return' }); + await result; + expect(output.buffer).toMatchSnapshot(); + expect(output.buffer.toString()).toMatch('2 items selected'); + }); });