diff --git a/README.md b/README.md index 8e425ce..64ee445 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ const os = await multiselect('Choose OS', { }); ``` +Use `autocomplete` to allow filtered choices. This can be usefull for a large list of choices. + ### `confirm()` ```ts @@ -177,6 +179,7 @@ export interface MultiselectOptions extends SharedOptions { maxVisible?: number; preSelectedChoices?: (Choice | string)[]; validators?: Validator[]; + autocomplete?: boolean; } export interface ConfirmOptions extends SharedOptions { diff --git a/index.d.ts b/index.d.ts index 92eab7c..ae8135c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -35,6 +35,7 @@ export interface MultiselectOptions extends SharedOptions { maxVisible?: number; preSelectedChoices?: (Choice | string)[]; validators?: Validator[]; + autocomplete?: boolean; } export interface ConfirmOptions extends SharedOptions { diff --git a/src/prompts/multiselect.js b/src/prompts/multiselect.js index cda8dc2..9459e39 100644 --- a/src/prompts/multiselect.js +++ b/src/prompts/multiselect.js @@ -10,6 +10,9 @@ import { AbstractPrompt } from "./abstract.js"; import { stripAnsi } from "../utils.js"; import { SYMBOLS } from "../constants.js"; +// CONSTANTS +const kRequiredChoiceProperties = ["label", "value"]; + export class MultiselectPrompt extends AbstractPrompt { #boundExitEvent = () => void 0; #boundKeyPressEvent = () => void 0; @@ -18,11 +21,42 @@ export class MultiselectPrompt extends AbstractPrompt { activeIndex = 0; selectedIndexes = []; questionMessage; + autocompleteValue = ""; get choices() { return this.options.choices; } + get filteredChoices() { + return this.options.autocomplete && this.autocompleteValue.length > 0 ? this.choices.filter((choice) => { + if (typeof choice === "string") { + if (this.autocompleteValue.includes(" ")) { + return this.autocompleteValue.split(" ").every((word) => choice.includes(word)) || + choice.includes(this.autocompleteValue); + } + + return choice.includes(this.autocompleteValue); + } + + if (this.autocompleteValue.includes(" ")) { + return this.autocompleteValue.split(" ").every((word) => choice.label.includes(word)) || + choice.label.includes(this.autocompleteValue); + } + + return choice.label.includes(this.autocompleteValue); + }) : this.choices; + } + + get longestChoice() { + return Math.max(...this.filteredChoices.map((choice) => { + if (typeof choice === "string") { + return choice.length; + } + + return choice.label.length; + })); + } + constructor(message, options) { const { stdin = process.stdin, @@ -46,31 +80,27 @@ export class MultiselectPrompt extends AbstractPrompt { throw new TypeError("Missing required param: choices"); } - this.longestChoice = Math.max(...choices.map((choice) => { + this.#validators = validators; + + for (const choice of choices) { if (typeof choice === "string") { - return choice.length; + continue; } - const kRequiredChoiceProperties = ["label", "value"]; - for (const prop of kRequiredChoiceProperties) { if (!choice[prop]) { this.destroy(); throw new TypeError(`Missing ${prop} for choice ${JSON.stringify(choice)}`); } } - - return choice.label.length; - })); - - this.#validators = validators; + } if (!preSelectedChoices) { return; } for (const choice of preSelectedChoices) { - const choiceIndex = this.choices.findIndex((item) => { + const choiceIndex = this.filteredChoices.findIndex((item) => { if (typeof item === "string") { return item === choice; } @@ -87,7 +117,7 @@ export class MultiselectPrompt extends AbstractPrompt { } #getFormattedChoice(choiceIndex) { - const choice = this.choices[choiceIndex]; + const choice = this.filteredChoices[choiceIndex]; if (typeof choice === "string") { return { value: choice, label: choice }; @@ -98,12 +128,12 @@ export class MultiselectPrompt extends AbstractPrompt { #getVisibleChoices() { const maxVisible = this.options.maxVisible || 8; - let startIndex = Math.min(this.choices.length - maxVisible, this.activeIndex - Math.floor(maxVisible / 2)); + let startIndex = Math.min(this.filteredChoices.length - maxVisible, this.activeIndex - Math.floor(maxVisible / 2)); if (startIndex < 0) { startIndex = 0; } - const endIndex = Math.min(startIndex + maxVisible, this.choices.length); + const endIndex = Math.min(startIndex + maxVisible, this.filteredChoices.length); return { startIndex, endIndex }; } @@ -112,12 +142,15 @@ export class MultiselectPrompt extends AbstractPrompt { const { startIndex, endIndex } = this.#getVisibleChoices(); this.lastRender = { startIndex, endIndex }; + if (this.options.autocomplete) { + this.write(`${SYMBOLS.Pointer} ${this.autocompleteValue}${EOL}`); + } for (let choiceIndex = startIndex; choiceIndex < endIndex; choiceIndex++) { const choice = this.#getFormattedChoice(choiceIndex); const isChoiceActive = choiceIndex === this.activeIndex; const isChoiceSelected = this.selectedIndexes.includes(choiceIndex); const showPreviousChoicesArrow = startIndex > 0 && choiceIndex === startIndex; - const showNextChoicesArrow = endIndex < this.choices.length && choiceIndex === endIndex - 1; + const showNextChoicesArrow = endIndex < this.filteredChoices.length && choiceIndex === endIndex - 1; let prefixArrow = " "; if (showPreviousChoicesArrow) { @@ -156,34 +189,30 @@ export class MultiselectPrompt extends AbstractPrompt { #onKeypress(...args) { const [resolve, render, _, key] = args; - if (key.name === "up") { - this.activeIndex = this.activeIndex === 0 ? this.choices.length - 1 : this.activeIndex - 1; + this.activeIndex = this.activeIndex === 0 ? this.filteredChoices.length - 1 : this.activeIndex - 1; render(); } else if (key.name === "down") { - this.activeIndex = this.activeIndex === this.choices.length - 1 ? 0 : this.activeIndex + 1; + this.activeIndex = this.activeIndex === this.filteredChoices.length - 1 ? 0 : this.activeIndex + 1; render(); } - else if (key.name === "a") { - this.selectedIndexes = this.selectedIndexes.length === this.choices.length ? [] : this.choices.map((_, index) => index); + else if (key.ctrl && key.name === "a") { + // eslint-disable-next-line max-len + this.selectedIndexes = this.selectedIndexes.length === this.filteredChoices.length ? [] : this.filteredChoices.map((_, index) => index); render(); } - else if (key.name === "space") { - const isChoiceSelected = this.selectedIndexes.includes(this.activeIndex); - - if (isChoiceSelected) { - this.selectedIndexes = this.selectedIndexes.filter((index) => index !== this.activeIndex); - } - else { - this.selectedIndexes.push(this.activeIndex); - } - + else if (key.name === "right") { + this.selectedIndexes.push(this.activeIndex); + render(); + } + else if (key.name === "left") { + this.selectedIndexes = this.selectedIndexes.filter((index) => index !== this.activeIndex); render(); } else if (key.name === "return") { - const labels = this.selectedIndexes.map((index) => this.choices[index].label ?? this.choices[index]); - const values = this.selectedIndexes.map((index) => this.choices[index].value ?? this.choices[index]); + const labels = this.selectedIndexes.map((index) => this.filteredChoices[index].label ?? this.filteredChoices[index]); + const values = this.selectedIndexes.map((index) => this.filteredChoices[index].value ?? this.filteredChoices[index]); for (const validator of this.#validators) { if (!validator.validate(values)) { @@ -207,6 +236,17 @@ export class MultiselectPrompt extends AbstractPrompt { resolve(values); } else { + if (!key.ctrl && this.options.autocomplete) { + // reset selected choices when user type + this.selectedIndexes = []; + this.activeIndex = 0; + if (key.name === "backspace" && this.autocompleteValue.length > 0) { + this.autocompleteValue = this.autocompleteValue.slice(0, -1); + } + else if (key.name !== "backspace") { + this.autocompleteValue += key.sequence; + } + } render(); } } @@ -236,6 +276,15 @@ export class MultiselectPrompt extends AbstractPrompt { this.clearLastLine(); linesToClear--; } + if (this.options.autocomplete) { + let linesToClear = Math.ceil( + wcwidth(`${SYMBOLS.Pointer} ${this.autocompleteValue}`) / this.stdout.columns + ); + while (linesToClear > 0) { + this.clearLastLine(); + linesToClear--; + } + } } if (clearRender) { @@ -249,7 +298,8 @@ export class MultiselectPrompt extends AbstractPrompt { } if (error) { - this.stdout.moveCursor(0, -2); + const linesToClear = Math.ceil(wcwidth(this.questionMessage) / this.stdout.columns) + 1; + this.stdout.moveCursor(0, -linesToClear); this.stdout.clearScreenDown(); this.#showQuestion(error); } @@ -270,7 +320,8 @@ export class MultiselectPrompt extends AbstractPrompt { #showQuestion(error = null) { let hint = kleur.gray( - `(Press ${kleur.bold("")} to toggle all, ${kleur.bold("")} to select, ${kleur.bold("")} to submit)` + // eslint-disable-next-line max-len + `(Press ${kleur.bold("")} to toggle all, ${kleur.bold("")} to select, ${kleur.bold("")} to toggle, ${kleur.bold("")} to submit)` ); if (error) { hint += ` ${kleur.red().bold(`[${error}]`)}`; diff --git a/test/multi-select-prompt.test.js b/test/multi-select-prompt.test.js index bfe90d3..a4481a8 100644 --- a/test/multi-select-prompt.test.js +++ b/test/multi-select-prompt.test.js @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ // Import Node.js Dependencies import assert from "node:assert"; import { describe, it } from "node:test"; @@ -10,10 +11,11 @@ import { PromptAgent } from "../src/prompt-agent.js"; import { multiselect, required } from "../index.js"; const kInputs = { - a: { name: "a" }, + toggleAll: { name: "a", ctrl: true }, down: { name: "down" }, return: { name: "return" }, - space: { name: "space" } + left: { name: "left" }, + right: { name: "right" } }; const kPromptAgent = PromptAgent.agent(); @@ -82,19 +84,19 @@ describe("MultiselectPrompt", () => { assert.deepEqual(input, []); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", "✖ Choose between foo & bar ›" ]); }); - it("When press then , it should return an array with first choice.", async() => { + it("When press then , it should return an array with first choice.", async() => { const message = "Choose between foo & bar"; const options = { choices: ["foo", "bar"] }; - const inputs = [kInputs.space, kInputs.return]; + const inputs = [kInputs.right, kInputs.return]; const logs = []; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( message, @@ -109,7 +111,7 @@ describe("MultiselectPrompt", () => { assert.deepEqual(input, ["foo"]); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", // we press so the first choice 'foo' is selected @@ -120,7 +122,7 @@ describe("MultiselectPrompt", () => { ]); }); - it("When press then then , it should return an array with the second choice.", async() => { + it("When press then then , it should return an array with the second choice.", async() => { const logs = []; const message = "Choose between foo & bar"; const options = { @@ -128,7 +130,7 @@ describe("MultiselectPrompt", () => { }; const inputs = [ kInputs.down, - kInputs.space, + kInputs.right, kInputs.return ]; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( @@ -143,7 +145,7 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", // We press , cursor moves from "foo" to "bar" @@ -158,16 +160,16 @@ describe("MultiselectPrompt", () => { assert.deepStrictEqual(input, ["bar"]); }); - it("When press then then then , it should return an array with all choice.", async() => { + it("When press then then then , it should return an array with all choice.", async() => { const logs = []; const message = "Choose between foo & bar"; const options = { choices: ["foo", "bar"] }; const inputs = [ - kInputs.space, + kInputs.right, kInputs.down, - kInputs.space, + kInputs.right, kInputs.return ]; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( @@ -182,7 +184,7 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", // we press so the first choice 'foo' is selected @@ -200,15 +202,15 @@ describe("MultiselectPrompt", () => { assert.deepStrictEqual(input, ["foo", "bar"]); }); - it("When press then then , it should return an empty array.", async() => { + it("When press then then , it should return an empty array.", async() => { const logs = []; const message = "Choose between foo & bar"; const options = { choices: ["foo", "bar"] }; const inputs = [ - kInputs.space, - kInputs.space, + kInputs.right, + kInputs.left, kInputs.return ]; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( @@ -223,7 +225,7 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", // we press so the first choice 'foo' is selected @@ -238,15 +240,15 @@ describe("MultiselectPrompt", () => { assert.deepStrictEqual(input, []); }); - it("When press , it should toggle all.", async() => { + it("When press , it should toggle all.", async() => { const logs = []; const message = "Choose between foo, bar & baz"; const options = { choices: ["foo", "bar", "baz"] }; const inputs = [ - kInputs.a, - kInputs.a, + kInputs.toggleAll, + kInputs.toggleAll, kInputs.return ]; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( @@ -261,15 +263,15 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose between foo, bar & baz (Press to toggle all, to select, to submit)", + "? Choose between foo, bar & baz (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", " ○ baz", - // we press , it toggle all + // we press , it toggle all " ● foo", " ● bar", " ● baz", - // we press , it toggle all + // we press , it toggle all " ○ foo", " ○ bar", " ○ baz", @@ -279,7 +281,7 @@ describe("MultiselectPrompt", () => { assert.deepStrictEqual(input, []); }); - it("It should work with choice objects.", async() => { + it("should work with choice objects.", async() => { const logs = []; const message = "Choose between foo & bar"; const options = { @@ -289,7 +291,7 @@ describe("MultiselectPrompt", () => { ] }; const inputs = [ - kInputs.space, + kInputs.right, kInputs.return ]; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( @@ -304,10 +306,10 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", - // we press so the first choice 'foo' is selected + // we press so the first choice 'foo' is selected " ● foo", " ○ bar", // we press so the first choice 'foo' is returned @@ -327,7 +329,7 @@ describe("MultiselectPrompt", () => { }; const inputs = [ kInputs.down, - kInputs.space, + kInputs.right, kInputs.return ]; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( @@ -342,13 +344,13 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", // we press so the last choice 'bar' is the active one " ○ foo", " ○ bar", - // we press so the last choice 'bar' is selected + // we press so the last choice 'bar' is selected " ○ foo", " ● bar", // we press so the last choice 'bar' is returned @@ -379,7 +381,7 @@ describe("MultiselectPrompt", () => { kInputs.down, kInputs.down, kInputs.down, - kInputs.space, + kInputs.right, kInputs.return ]; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( @@ -394,7 +396,7 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose option (Press to toggle all, to select, to submit)", + "? Choose option (Press to toggle all, to select, to toggle, to submit)", // Firstly, it renders the first 5 choices. (as maxVisible is 5) " ○ Option 1 ", " ○ Option 2 ", @@ -422,7 +424,7 @@ describe("MultiselectPrompt", () => { " ○ Option 4 ", " ○ Option 5 ", "⭣ ○ Option 6 ", - // The user presses the space key and the active option is selected + // The user presses the right key and the active option is selected "⭡ ○ Option 2 ", " ○ Option 3 ", " ● Option 4 ", @@ -475,7 +477,7 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", // bar is pre-selected " ○ foo", " ● bar", @@ -506,7 +508,7 @@ describe("MultiselectPrompt", () => { ]); }); - it("It should render with validation error.", async() => { + it("should render with validation error.", async() => { const logs = []; const message = "Choose between foo & bar"; const options = { @@ -515,7 +517,7 @@ describe("MultiselectPrompt", () => { }; const inputs = [ kInputs.return, - kInputs.space, + kInputs.right, kInputs.return ]; const multiselectPrompt = await TestingPrompt.MultiselectPrompt( @@ -530,14 +532,14 @@ describe("MultiselectPrompt", () => { const input = await multiselectPrompt.multiselect(); assert.deepStrictEqual(logs, [ - "? Choose between foo & bar (Press to toggle all, to select, to submit)", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit)", " ○ foo", " ○ bar", // we press so it re-render question with error - "? Choose between foo & bar (Press to toggle all, to select, to submit) [required]", + "? Choose between foo & bar (Press to toggle all, to select, to toggle, to submit) [required]", " ○ foo", " ○ bar", - // we press so it select 'foo' + // we press so it select 'foo' " ● foo", " ○ bar", // we press so 'foo' is returned @@ -545,4 +547,167 @@ describe("MultiselectPrompt", () => { ]); assert.deepEqual(input, ["foo"]); }); + + it("should filter values with autocomplete", async() => { + const logs = []; + const message = "Choose between foo, bar & baz"; + const options = { + choices: ["foo", "bar", "baz"], + autocomplete: true + }; + const inputs = [ + { sequence: "b" }, + { sequence: "a" }, + kInputs.toggleAll, + kInputs.return + ]; + const multiselectPrompt = await TestingPrompt.MultiselectPrompt( + message, + { + ...options, + inputs, + onStdoutWrite: (log) => logs.push(log) + } + ); + + const input = await multiselectPrompt.multiselect(); + + assert.deepStrictEqual(logs, [ + "? Choose between foo, bar & baz (Press to toggle all, to select, to toggle, to submit)", + "› ", + " ○ foo", + " ○ bar", + " ○ baz", + // we press so it filters values with 'b' + "› b", + " ○ bar", + " ○ baz", + // we press so it filters values with 'ba' + "› ba", + " ○ bar", + " ○ baz", + // we press so it select all + "› ba", + " ● bar", + " ● baz", + // we press so 'foo' is returned + "✔ Choose between foo, bar & baz › bar, baz" + ]); + assert.deepEqual(input, ["bar", "baz"]); + }); + + it("should filter all choices with autocomplete when using backspace", async() => { + const logs = []; + const message = "Choose between foo, bar & baz"; + const options = { + choices: ["foo", "bar", "baz"], + autocomplete: true + }; + const inputs = [ + { sequence: "b" }, + { sequence: "a" }, + { name: "backspace" }, + { name: "backspace" }, + kInputs.toggleAll, + kInputs.return + ]; + const multiselectPrompt = await TestingPrompt.MultiselectPrompt( + message, + { + ...options, + inputs, + onStdoutWrite: (log) => logs.push(log) + } + ); + + const input = await multiselectPrompt.multiselect(); + + assert.deepStrictEqual(logs, [ + "? Choose between foo, bar & baz (Press to toggle all, to select, to toggle, to submit)", + "› ", + " ○ foo", + " ○ bar", + " ○ baz", + // we press so it filters values with 'b' + "› b", + " ○ bar", + " ○ baz", + // we press so it filters values with 'ba' + "› ba", + " ○ bar", + " ○ baz", + // we press so it filters values with 'b' + "› b", + " ○ bar", + " ○ baz", + // we press so it filters all values + "› ", + " ○ foo", + " ○ bar", + " ○ baz", + // we press so it select all + "› ", + " ● foo", + " ● bar", + " ● baz", + // we press so 'foo' is returned + "✔ Choose between foo, bar & baz › foo, bar, baz" + ]); + assert.deepEqual(input, ["foo", "bar", "baz"]); + }); + + it("validators should works with autocomplete", async() => { + const logs = []; + const message = "Choose between foo, bar & baz"; + const options = { + choices: ["foo", "bar", "baz"], + autocomplete: true, + validators: [required()] + }; + const inputs = [ + { sequence: "b" }, + { sequence: "a" }, + kInputs.return, + kInputs.toggleAll, + kInputs.return + ]; + const multiselectPrompt = await TestingPrompt.MultiselectPrompt( + message, + { + ...options, + inputs, + onStdoutWrite: (log) => logs.push(log) + } + ); + + const input = await multiselectPrompt.multiselect(); + + assert.deepStrictEqual(logs, [ + "? Choose between foo, bar & baz (Press to toggle all, to select, to toggle, to submit)", + "› ", + " ○ foo", + " ○ bar", + " ○ baz", + // we press so it filters values with 'b' + "› b", + " ○ bar", + " ○ baz", + // we press so it filters values with 'ba' + "› ba", + " ○ bar", + " ○ baz", + // we press so it re-render question with error + "? Choose between foo, bar & baz (Press to toggle all, to select, to toggle, to submit) [required]", + "› ba", + " ○ bar", + " ○ baz", + // we press so it select all + "› ba", + " ● bar", + " ● baz", + // we press so 'foo' is returned + "✔ Choose between foo, bar & baz › bar, baz" + ]); + assert.deepEqual(input, ["bar", "baz"]); + }); }); diff --git a/test/select-prompt.test.js b/test/select-prompt.test.js index 4b7bb04..d94dba6 100755 --- a/test/select-prompt.test.js +++ b/test/select-prompt.test.js @@ -120,7 +120,7 @@ describe("SelectPrompt", () => { assert.equal(input, "bar"); }); - it("It should work with choice objects.", async() => { + it("should work with choice objects.", async() => { const logs = []; const message = "Choose between foo & bar"; const options = { @@ -222,7 +222,7 @@ describe("SelectPrompt", () => { assert.equal(input, "foo"); }); - it("It should ignore foo.", async() => { + it("should ignore foo.", async() => { const logs = []; const message = "Choose between foo & bar"; const options = {