@topcli/prompts 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,15 +27,19 @@ $ yarn add @topcli/prompts
27
27
  You can locally run `node ./demo.js`
28
28
 
29
29
  ```js
30
- import { question, confirm, select } from "@topcli/prompts";
30
+ import { question, confirm, select, multiselect } from "@topcli/prompts";
31
31
 
32
32
  const kTestRunner = ["node", "tap", "tape", "vitest", "mocha", "ava"];
33
33
 
34
34
  const name = await question("Project name ?", { defaultValue: "foo" });
35
35
  const runner = await select("Choose a test runner", { choices: kTestRunner, maxVisible: 5 });
36
36
  const isCLI = await confirm("Your project is a CLI ?", { initial: true });
37
+ const os = await multiselect("Choose OS", {
38
+ choices: ["linux", "mac", "windows"],
39
+ preSelectedChoices: ["linux"]
40
+ });
37
41
 
38
- console.log(name, runner, isCLI);
42
+ console.log(name, runner, isCLI, os);
39
43
  ```
40
44
 
41
45
  ## API
@@ -47,6 +51,7 @@ question(message: string, options?: PromptOptions): Promise<string>
47
51
  ```
48
52
 
49
53
  Simple prompt, similar to `rl.question()` with an improved UI.
54
+ Use `options.secure` if you need to hide both input and answer.
50
55
  Use `options.validators` to handle user input.
51
56
 
52
57
  **Example**
@@ -83,6 +88,26 @@ select(message: string, options: SelectOptions): Promise<string>
83
88
  Scrollable select depending `maxVisible` (default `8`).
84
89
  Use `ignoreValues` to skip result render & clear lines after a selected one.
85
90
 
91
+ ### `multiselect()`
92
+
93
+ ```ts
94
+ multiselect(message: string, options: MultiselectOptions): Promise<[string]>
95
+ ```
96
+
97
+ Scrollable multiselect depending `maxVisible` (default `8`).
98
+ Use `preSelectedChoices` to pre-select choices.
99
+
100
+ Use `validators` to handle user input.
101
+
102
+ **Example**
103
+
104
+ ```js
105
+ const os = await multiselect('Choose OS', {
106
+ choices: ["linux", "mac", "windows"]
107
+ validators: [required()]
108
+ });
109
+ ```
110
+
86
111
  ### `confirm()`
87
112
 
88
113
  ```ts
@@ -109,6 +134,7 @@ assert.equal(input, "John");
109
134
  > - When using `question()`, `validators` functions will not be executed.
110
135
  > - When using `select()`, the answer can be different from the available choices.
111
136
  > - When using `confirm()`, the answer can be any type other than boolean.
137
+ > - etc
112
138
  > **Use with caution**
113
139
 
114
140
  ## Interfaces
@@ -131,6 +157,7 @@ export interface Validator {
131
157
  export interface QuestionOptions extends SharedOptions {
132
158
  defaultValue?: string;
133
159
  validators?: Validator[];
160
+ secure?: boolean;
134
161
  }
135
162
 
136
163
  export interface Choice {
@@ -145,6 +172,13 @@ export interface SelectOptions extends SharedOptions {
145
172
  ignoreValues?: (string | number | boolean)[];
146
173
  }
147
174
 
175
+ export interface MultiselectOptions extends SharedOptions {
176
+ choices: (Choice | string)[];
177
+ maxVisible?: number;
178
+ preSelectedChoices?: (Choice | string)[];
179
+ validators?: Validator[];
180
+ }
181
+
148
182
  export interface ConfirmOptions extends SharedOptions {
149
183
  initial?: boolean;
150
184
  }
package/index.d.ts CHANGED
@@ -15,9 +15,9 @@ export interface Validator {
15
15
  export interface QuestionOptions extends SharedOptions {
16
16
  defaultValue?: string;
17
17
  validators?: Validator[];
18
+ secure?: boolean;
18
19
  }
19
20
 
20
-
21
21
  export interface Choice {
22
22
  value: any;
23
23
  label: string;
@@ -30,12 +30,20 @@ export interface SelectOptions extends SharedOptions {
30
30
  ignoreValues?: (string | number | boolean)[];
31
31
  }
32
32
 
33
+ export interface MultiselectOptions extends SharedOptions {
34
+ choices: (Choice | string)[];
35
+ maxVisible?: number;
36
+ preSelectedChoices?: (Choice | string)[];
37
+ validators?: Validator[];
38
+ }
39
+
33
40
  export interface ConfirmOptions extends SharedOptions {
34
41
  initial?: boolean;
35
42
  }
36
43
 
37
44
  export function question(message: string, options?: QuestionOptions): Promise<string>;
38
45
  export function select(message: string, options: SelectOptions): Promise<string>;
46
+ export function multiselect(message: string, options: MultiselectOptions): Promise<string[]>;
39
47
  export function confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
40
48
 
41
49
  export function required(): Validator;
package/index.js CHANGED
@@ -4,6 +4,7 @@ import { SelectPrompt } from "./src/select-prompt.js";
4
4
  import { QuestionPrompt } from "./src/question-prompt.js";
5
5
  import { required } from "./src/validators.js";
6
6
  import { PromptAgent } from "./src/prompt-agent.js";
7
+ import { MultiselectPrompt } from "./src/multiselect-prompt.js";
7
8
 
8
9
  export async function question(message, options = {}) {
9
10
  const questionPrompt = new QuestionPrompt(message, options);
@@ -23,4 +24,10 @@ export async function confirm(message, options) {
23
24
  return confirmPrompt.confirm();
24
25
  }
25
26
 
27
+ export async function multiselect(message, options) {
28
+ const multiselectPrompt = new MultiselectPrompt(message, options);
29
+
30
+ return multiselectPrompt.multiselect();
31
+ }
32
+
26
33
  export { required, PromptAgent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topcli/prompts",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Node.js user input library for command-line interfaces.",
5
5
  "scripts": {
6
6
  "test": "node --no-warnings=ExperimentalWarning --loader=esmock --test test/",
@@ -23,11 +23,11 @@
23
23
  "license": "ISC",
24
24
  "type": "module",
25
25
  "devDependencies": {
26
- "@nodesecure/eslint-config": "^1.7.0",
27
- "@types/node": "^20.4.5",
28
- "c8": "^8.0.0",
29
- "eslint": "^8.44.0",
30
- "esmock": "^2.3.1"
26
+ "@nodesecure/eslint-config": "^1.8.0",
27
+ "@types/node": "^20.8.5",
28
+ "c8": "^8.0.1",
29
+ "eslint": "^8.51.0",
30
+ "esmock": "^2.5.2"
31
31
  },
32
32
  "dependencies": {
33
33
  "is-unicode-supported": "^1.3.0",
@@ -1,6 +1,7 @@
1
1
  // Import Node.js Dependencies
2
2
  import { EOL } from "node:os";
3
3
  import { createInterface } from "node:readline";
4
+ import { Writable } from "node:stream";
4
5
 
5
6
  // Import Third-party Dependencies
6
7
  import stripAnsi from "strip-ansi";
@@ -23,11 +24,23 @@ export class AbstractPrompt {
23
24
  this.message = message;
24
25
  this.history = [];
25
26
  this.agent = PromptAgent.agent();
27
+ this.mute = false;
26
28
 
27
29
  if (this.stdout.isTTY) {
28
30
  this.stdin.setRawMode(true);
29
31
  }
30
- this.rl = createInterface({ input, output });
32
+ this.rl = createInterface({
33
+ input,
34
+ output: new Writable({
35
+ write: (chunk, encoding, callback) => {
36
+ if (!this.mute) {
37
+ this.stdout.write(chunk, encoding);
38
+ }
39
+ callback();
40
+ }
41
+ }),
42
+ terminal: true
43
+ });
31
44
  }
32
45
 
33
46
  write(data) {
@@ -47,7 +60,7 @@ export class AbstractPrompt {
47
60
 
48
61
  const lastLineRows = Math.ceil(stripAnsi(lastLine).length / this.stdout.columns);
49
62
 
50
- this.stdout.moveCursor(0, -lastLineRows);
63
+ this.stdout.moveCursor(-this.stdout.columns, -lastLineRows);
51
64
  this.stdout.clearScreenDown();
52
65
  }
53
66
 
package/src/constants.js CHANGED
@@ -7,14 +7,18 @@ const kMainSymbols = {
7
7
  cross: "✖",
8
8
  pointer: "›",
9
9
  previous: "⭡",
10
- next: "⭣"
10
+ next: "⭣",
11
+ active: "●",
12
+ inactive: "○"
11
13
  };
12
14
  const kFallbackSymbols = {
13
15
  tick: "√",
14
16
  cross: "×",
15
17
  pointer: ">",
16
18
  previous: "↑",
17
- next: "↓"
19
+ next: "↓",
20
+ active: "(+)",
21
+ inactive: "(-)"
18
22
  };
19
23
  const kSymbols = isUnicodeSupported() ? kMainSymbols : kFallbackSymbols;
20
24
  const kPointer = kleur.gray(kSymbols.pointer);
@@ -27,5 +31,7 @@ export const SYMBOLS = {
27
31
  Previous: kSymbols.previous,
28
32
  Next: kSymbols.next,
29
33
  ShowCursor: "\x1B[?25h",
30
- HideCursor: "\x1B[?25l"
34
+ HideCursor: "\x1B[?25l",
35
+ Active: kleur.cyan(kSymbols.active),
36
+ Inactive: kleur.gray(kSymbols.inactive)
31
37
  };
@@ -0,0 +1,257 @@
1
+ // Import Node.js Dependencies
2
+ import { EOL } from "node:os";
3
+
4
+ // Import Third-party Dependencies
5
+ import kleur from "kleur";
6
+
7
+ // Import Internal Dependencies
8
+ import { AbstractPrompt } from "./abstract-prompt.js";
9
+ import { SYMBOLS } from "./constants.js";
10
+
11
+ export class MultiselectPrompt extends AbstractPrompt {
12
+ #validators;
13
+
14
+ activeIndex = 0;
15
+ selectedIndexes = [];
16
+
17
+ get choices() {
18
+ return this.options.choices;
19
+ }
20
+
21
+ constructor(message, options) {
22
+ const {
23
+ stdin = process.stdin,
24
+ stdout = process.stdout,
25
+ choices,
26
+ preSelectedChoices,
27
+ validators = []
28
+ } = options ?? {};
29
+
30
+ super(message, stdin, stdout);
31
+
32
+ if (!options) {
33
+ this.destroy();
34
+ throw new TypeError("Missing required options");
35
+ }
36
+
37
+ this.options = options;
38
+
39
+ if (!choices?.length) {
40
+ this.destroy();
41
+ throw new TypeError("Missing required param: choices");
42
+ }
43
+
44
+ this.longestChoice = Math.max(...choices.map((choice) => {
45
+ if (typeof choice === "string") {
46
+ return choice.length;
47
+ }
48
+
49
+ const kRequiredChoiceProperties = ["label", "value"];
50
+
51
+ for (const prop of kRequiredChoiceProperties) {
52
+ if (!choice[prop]) {
53
+ this.destroy();
54
+ throw new TypeError(`Missing ${prop} for choice ${JSON.stringify(choice)}`);
55
+ }
56
+ }
57
+
58
+ return choice.label.length;
59
+ }));
60
+
61
+ this.#validators = validators;
62
+
63
+ if (!preSelectedChoices) {
64
+ return;
65
+ }
66
+
67
+ for (const choice of preSelectedChoices) {
68
+ const choiceIndex = this.choices.findIndex((item) => {
69
+ if (typeof item === "string") {
70
+ return item === choice;
71
+ }
72
+
73
+ return item.value === choice;
74
+ });
75
+
76
+ if (choiceIndex === -1) {
77
+ throw new Error(`Invalid pre-selected choice: ${choice.value ?? choice}`);
78
+ }
79
+
80
+ this.selectedIndexes.push(choiceIndex);
81
+ }
82
+ }
83
+
84
+ #getFormattedChoice(choiceIndex) {
85
+ const choice = this.choices[choiceIndex];
86
+
87
+ if (typeof choice === "string") {
88
+ return { value: choice, label: choice };
89
+ }
90
+
91
+ return choice;
92
+ }
93
+
94
+ #getVisibleChoices() {
95
+ const maxVisible = this.options.maxVisible || 8;
96
+ let startIndex = Math.min(this.choices.length - maxVisible, this.activeIndex - Math.floor(maxVisible / 2));
97
+ if (startIndex < 0) {
98
+ startIndex = 0;
99
+ }
100
+
101
+ const endIndex = Math.min(startIndex + maxVisible, this.choices.length);
102
+
103
+ return { startIndex, endIndex };
104
+ }
105
+
106
+ #showChoices() {
107
+ const { startIndex, endIndex } = this.#getVisibleChoices();
108
+ this.lastRender = { startIndex, endIndex };
109
+
110
+ for (let choiceIndex = startIndex; choiceIndex < endIndex; choiceIndex++) {
111
+ const choice = this.#getFormattedChoice(choiceIndex);
112
+ const isChoiceActive = choiceIndex === this.activeIndex;
113
+ const isChoiceSelected = this.selectedIndexes.includes(choiceIndex);
114
+ const showPreviousChoicesArrow = startIndex > 0 && choiceIndex === startIndex;
115
+ const showNextChoicesArrow = endIndex < this.choices.length && choiceIndex === endIndex - 1;
116
+
117
+ let prefixArrow = " ";
118
+ if (showPreviousChoicesArrow) {
119
+ prefixArrow = SYMBOLS.Previous + " ";
120
+ }
121
+ else if (showNextChoicesArrow) {
122
+ prefixArrow = SYMBOLS.Next + " ";
123
+ }
124
+
125
+ const prefix = `${prefixArrow}${isChoiceSelected ? SYMBOLS.Active : SYMBOLS.Inactive}`;
126
+ const formattedLabel = choice.label.padEnd(
127
+ this.longestChoice < 10 ? this.longestChoice : 0
128
+ );
129
+ const formattedDescription = choice.description ? ` - ${choice.description}` : "";
130
+ const color = isChoiceActive ? kleur.white().bold : kleur.gray;
131
+ const str = `${prefix} ${color(`${formattedLabel}${formattedDescription}`)}${EOL}`;
132
+
133
+ this.write(str);
134
+ }
135
+ }
136
+
137
+ #showAnsweredQuestion(choices, isAgentAnswer = false) {
138
+ const prefixSymbol = this.selectedIndexes.length === 0 && !isAgentAnswer ? SYMBOLS.Cross : SYMBOLS.Tick;
139
+ const prefix = `${prefixSymbol} ${kleur.bold(this.message)} ${SYMBOLS.Pointer}`;
140
+ const formattedChoice = kleur.yellow(choices);
141
+
142
+ this.write(`${prefix}${choices ? ` ${formattedChoice}` : ""}${EOL}`);
143
+ }
144
+
145
+ async multiselect() {
146
+ if (this.agent.nextAnswers.length > 0) {
147
+ const answer = this.agent.nextAnswers.shift();
148
+ this.#showAnsweredQuestion(answer, true);
149
+ this.destroy();
150
+
151
+ return answer;
152
+ }
153
+
154
+ this.write(SYMBOLS.HideCursor);
155
+ this.#showQuestion();
156
+
157
+ const render = (options = {}) => {
158
+ const {
159
+ initialRender = false,
160
+ clearRender = false,
161
+ error = null
162
+ } = options;
163
+
164
+ if (!initialRender) {
165
+ let linesToClear = this.lastRender.endIndex - this.lastRender.startIndex;
166
+ while (linesToClear > 0) {
167
+ this.clearLastLine();
168
+ linesToClear--;
169
+ }
170
+ }
171
+
172
+ if (clearRender) {
173
+ this.stdout.moveCursor(0, -2);
174
+ this.stdout.clearScreenDown();
175
+
176
+ return;
177
+ }
178
+
179
+ if (error) {
180
+ this.stdout.moveCursor(0, -2);
181
+ this.stdout.clearScreenDown();
182
+ this.#showQuestion(error);
183
+ }
184
+
185
+ this.#showChoices();
186
+ };
187
+
188
+ render({ initialRender: true });
189
+
190
+ return new Promise((resolve) => {
191
+ const onKeypress = (value, key) => {
192
+ if (key.name === "up") {
193
+ this.activeIndex = this.activeIndex === 0 ? this.choices.length - 1 : this.activeIndex - 1;
194
+ render();
195
+ }
196
+ else if (key.name === "down") {
197
+ this.activeIndex = this.activeIndex === this.choices.length - 1 ? 0 : this.activeIndex + 1;
198
+ render();
199
+ }
200
+ else if (key.name === "a") {
201
+ this.selectedIndexes = this.selectedIndexes.length === this.choices.length ? [] : this.choices.map((_, index) => index);
202
+ render();
203
+ }
204
+ else if (key.name === "space") {
205
+ const isChoiceSelected = this.selectedIndexes.includes(this.activeIndex);
206
+
207
+ if (isChoiceSelected) {
208
+ this.selectedIndexes = this.selectedIndexes.filter((index) => index !== this.activeIndex);
209
+ }
210
+ else {
211
+ this.selectedIndexes.push(this.activeIndex);
212
+ }
213
+
214
+ render();
215
+ }
216
+ else if (key.name === "return") {
217
+ const labels = this.selectedIndexes.map((index) => this.choices[index].label ?? this.choices[index]);
218
+ const values = this.selectedIndexes.map((index) => this.choices[index].value ?? this.choices[index]);
219
+
220
+ for (const validator of this.#validators) {
221
+ if (!validator.validate(values)) {
222
+ const error = validator.error(values);
223
+ render({ error });
224
+
225
+ return;
226
+ }
227
+ }
228
+
229
+ this.stdin.off("keypress", onKeypress);
230
+
231
+ render({ clearRender: true });
232
+
233
+
234
+ this.#showAnsweredQuestion(labels.join(", "));
235
+
236
+ this.write(SYMBOLS.ShowCursor);
237
+ this.destroy();
238
+
239
+ resolve(values);
240
+ }
241
+ };
242
+
243
+ this.stdin.on("keypress", onKeypress);
244
+ });
245
+ }
246
+
247
+ #showQuestion(error = null) {
248
+ let hint = kleur.gray(
249
+ `(Press ${kleur.bold("<a>")} to toggle all, ${kleur.bold("<space>")} to select, ${kleur.bold("<return>")} to submit)`
250
+ );
251
+ if (error) {
252
+ hint += ` ${kleur.red().bold(`[${error}]`)}`;
253
+ }
254
+
255
+ this.write(`${SYMBOLS.QuestionMark} ${kleur.bold(this.message)} ${hint}${EOL}`);
256
+ }
257
+ }
@@ -10,13 +10,15 @@ import { SYMBOLS } from "./constants.js";
10
10
 
11
11
  export class QuestionPrompt extends AbstractPrompt {
12
12
  #validators;
13
+ #secure;
13
14
 
14
15
  constructor(message, options = {}) {
15
16
  const {
16
17
  stdin = process.stdin,
17
18
  stdout = process.stdout,
18
19
  defaultValue,
19
- validators = []
20
+ validators = [],
21
+ secure = false
20
22
  } = options;
21
23
 
22
24
  super(message, stdin, stdout);
@@ -28,6 +30,7 @@ export class QuestionPrompt extends AbstractPrompt {
28
30
  this.defaultValue = defaultValue;
29
31
  this.tip = this.defaultValue ? ` (${this.defaultValue})` : "";
30
32
  this.#validators = validators;
33
+ this.#secure = Boolean(secure);
31
34
  this.questionSuffixError = "";
32
35
  }
33
36
 
@@ -37,9 +40,11 @@ export class QuestionPrompt extends AbstractPrompt {
37
40
 
38
41
  this.rl.question(questionQuery, (answer) => {
39
42
  this.history.push(questionQuery + answer);
43
+ this.mute = false;
40
44
 
41
45
  resolve(answer);
42
46
  });
47
+ this.mute = this.#secure;
43
48
  });
44
49
  }
45
50
 
@@ -54,8 +59,7 @@ export class QuestionPrompt extends AbstractPrompt {
54
59
 
55
60
  #writeAnswer() {
56
61
  const prefix = this.answer ? SYMBOLS.Tick : SYMBOLS.Cross;
57
- const answer = kleur.yellow(this.answer ?? "");
58
-
62
+ const answer = kleur.yellow(this.#secure ? "CONFIDENTIAL" : this.answer ?? "");
59
63
  this.write(`${prefix} ${kleur.bold(this.message)} ${SYMBOLS.Pointer} ${answer}${EOL}`);
60
64
  }
61
65
 
package/src/validators.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export function required() {
2
2
  return {
3
- validate: (input) => input !== "",
3
+ validate: (input) => (Array.isArray(input) ? input.length > 0 : Boolean(input)),
4
4
  error: () => "required"
5
5
  };
6
6
  }