From 5d56a1413ca9e44c32bd2eed044aa0c04d91ab21 Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Sat, 19 Jul 2025 05:55:33 +0700 Subject: [PATCH 1/2] feat: add invert selection for multiselect prompt --- .changeset/upset-showers-grow.md | 5 +++++ packages/core/src/prompts/multi-select.ts | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 .changeset/upset-showers-grow.md diff --git a/.changeset/upset-showers-grow.md b/.changeset/upset-showers-grow.md new file mode 100644 index 00000000..ca50c0a1 --- /dev/null +++ b/.changeset/upset-showers-grow.md @@ -0,0 +1,5 @@ +--- +"@clack/core": patch +--- + +add invert selection for multiselect prompt diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index bbb392c8..b38e3357 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -20,6 +20,11 @@ export default class MultiSelectPrompt extends Prompt< this.value = allSelected ? [] : this.options.map((v) => v.value); } + private toggleInvert() { + const notSelected = this.options.filter((v) => !this.value.includes(v.value)); + this.value = notSelected.map((v) => v.value); + } + private toggleValue() { if (this.value === undefined) { this.value = []; @@ -43,6 +48,9 @@ export default class MultiSelectPrompt extends Prompt< if (char === 'a') { this.toggleAll(); } + if (char === 'i') { + this.toggleInvert(); + } }); this.on('cursor', (key) => { From 3d58aad4d8f0c8f84ce0d8a42da005fc17bd106c Mon Sep 17 00:00:00 2001 From: hyperz111 Date: Tue, 12 Aug 2025 18:59:03 +0700 Subject: [PATCH 2/2] add test file for multiselect core prompt --- .../core/test/prompts/multi-select.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/core/test/prompts/multi-select.test.ts diff --git a/packages/core/test/prompts/multi-select.test.ts b/packages/core/test/prompts/multi-select.test.ts new file mode 100644 index 00000000..3e0fc8ae --- /dev/null +++ b/packages/core/test/prompts/multi-select.test.ts @@ -0,0 +1,134 @@ +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as MultiSelectPrompt } from '../../src/prompts/multi-select.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +describe('MultiSelectPrompt', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders render() result', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar' }], + }); + instance.prompt(); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + describe('cursor', () => { + test('cursor is index of selected item', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar' }], + }); + + instance.prompt(); + + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(1); + }); + + test('cursor loops around', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], + }); + + instance.prompt(); + + expect(instance.cursor).to.equal(0); + input.emit('keypress', 'up', { name: 'up' }); + expect(instance.cursor).to.equal(2); + input.emit('keypress', 'down', { name: 'down' }); + expect(instance.cursor).to.equal(0); + }); + + test('left behaves as up', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar' }, { value: 'baz' }], + }); + + instance.prompt(); + + input.emit('keypress', 'left', { name: 'left' }); + expect(instance.cursor).to.equal(2); + }); + + test('right behaves as down', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar' }], + }); + + instance.prompt(); + + input.emit('keypress', 'left', { name: 'left' }); + expect(instance.cursor).to.equal(1); + }); + + test('initial values is selected', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar' }], + initialValues: ['bar'], + }); + instance.prompt(); + expect(instance.value).toEqual(['bar']); + }); + + test('select all when press "a" key', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar' }], + }); + instance.prompt(); + + input.emit('keypress', 'down', { name: 'down' }); + input.emit('keypress', 'space', { name: 'space' }); + input.emit('keypress', 'a', { name: 'a' }); + expect(instance.value).toEqual(['foo', 'bar']); + }); + + test('select invert when press "i" key', () => { + const instance = new MultiSelectPrompt({ + input, + output, + render: () => 'foo', + options: [{ value: 'foo' }, { value: 'bar' }], + }); + instance.prompt(); + + input.emit('keypress', 'down', { name: 'down' }); + input.emit('keypress', 'space', { name: 'space' }); + input.emit('keypress', 'i', { name: 'i' }); + expect(instance.value).toEqual(['foo']); + }); + }); +});