@topcli/prompts 0.1.0 → 0.1.1

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
@@ -43,10 +43,24 @@ console.log(name, runner, isCLI)
43
43
  ### `prompt()`
44
44
 
45
45
  ```ts
46
- prompt(message: string): Promise<string>
46
+ prompt(message: string, options?: PromptOptions): Promise<string>
47
47
  ```
48
48
 
49
49
  Simple prompt, similar to `rl.question()` with an improved UI.
50
+ Use `options.validators` to handle user input.
51
+
52
+ **Example**
53
+
54
+ ```js
55
+ const packageName = await prompt('Package name', {
56
+ validators: [
57
+ {
58
+ validate: (value) => !existsSync(join(process.cwd(), value)),
59
+ error: (value) => `Folder ${value} already exists`
60
+ }
61
+ ]
62
+ })
63
+ ```
50
64
 
51
65
  ### `select()`
52
66
 
@@ -67,6 +81,14 @@ Boolean prompt, return `options.initial` if user input is different from "y"/"ye
67
81
 
68
82
  ## Interfaces
69
83
 
84
+ ```ts
85
+ export interface PromptOptions {
86
+ validators?: {
87
+ validate: (input: string) => boolean;
88
+ error: (input: string) => string;
89
+ }[];
90
+ }
91
+ ```
70
92
  ```ts
71
93
  export interface Choice {
72
94
  value: any;
package/index.d.ts CHANGED
@@ -1,3 +1,10 @@
1
+ export interface PromptOptions {
2
+ validators?: {
3
+ validate: (input: string) => boolean;
4
+ error: (input: string) => string;
5
+ }[];
6
+ }
7
+
1
8
  export interface Choice {
2
9
  value: any;
3
10
  label: string;
@@ -14,6 +21,6 @@ export interface ConfirmOptions {
14
21
  initial?: boolean;
15
22
  }
16
23
 
17
- export function prompt(message: string): Promise<string>;
24
+ export function prompt(message: string, options?: PromptOptions): Promise<string>;
18
25
  export function select(message: string, options: SelectOptions): Promise<string>;
19
26
  export function confirm(message: string, options?: ConfirmOptions): Promise<string>;
package/index.js CHANGED
@@ -3,8 +3,8 @@ import { ConfirmPrompt } from "./src/confirm-prompt.js";
3
3
  import { SelectPrompt } from "./src/select-prompt.js";
4
4
  import { TextPrompt } from "./src/text-prompt.js";
5
5
 
6
- export async function prompt(message) {
7
- const textPrompt = new TextPrompt(message);
6
+ export async function prompt(message, options = {}) {
7
+ const textPrompt = new TextPrompt(message, options);
8
8
 
9
9
  return textPrompt.question();
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topcli/prompts",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Node.js user input library for command-line interfaces.",
5
5
  "scripts": {
6
6
  "test": "node --loader=esmock --test ./test/**.test.js",
@@ -26,12 +26,11 @@
26
26
  "@nodesecure/eslint-config": "^1.7.0",
27
27
  "c8": "^7.13.0",
28
28
  "eslint": "^8.38.0",
29
- "esmock": "^2.1.0",
30
- "snazzy": "^9.0.0",
31
- "strip-ansi": "^7.0.1"
29
+ "esmock": "^2.1.0"
32
30
  },
33
31
  "dependencies": {
34
- "ansi-styles": "^6.2.1"
32
+ "ansi-styles": "^6.2.1",
33
+ "strip-ansi": "^7.0.1"
35
34
  },
36
35
  "engines": {
37
36
  "node": ">=14"
@@ -1,6 +1,10 @@
1
1
  // Import Node.js Dependencies
2
+ import { EOL } from "node:os";
2
3
  import { createInterface } from "node:readline";
3
4
 
5
+ // Import Third-party Dependencies
6
+ import stripAnsi from "strip-ansi";
7
+
4
8
  export class AbstractPrompt {
5
9
  constructor(message, input = process.stdin, output = process.stdout) {
6
10
  if (this.constructor === AbstractPrompt) {
@@ -14,6 +18,7 @@ export class AbstractPrompt {
14
18
  this.stdin = input;
15
19
  this.stdout = output;
16
20
  this.message = message;
21
+ this.history = [];
17
22
 
18
23
  if (this.stdout.isTTY) {
19
24
  this.stdin.setRawMode(true);
@@ -21,9 +26,25 @@ export class AbstractPrompt {
21
26
  this.rl = createInterface({ input, output });
22
27
  }
23
28
 
29
+ write(data) {
30
+ const formattedData = stripAnsi(data).replace(EOL, "");
31
+ if (formattedData) {
32
+ this.history.push(formattedData);
33
+ }
34
+
35
+ return this.stdout.write(data);
36
+ }
37
+
24
38
  clearLastLine() {
25
- this.stdout.moveCursor(0, -1);
26
- this.stdout.clearLine();
39
+ const lastLine = this.history.pop();
40
+ if (!lastLine) {
41
+ return;
42
+ }
43
+
44
+ const lastLineRows = Math.ceil(stripAnsi(lastLine).length / this.stdout.columns);
45
+
46
+ this.stdout.moveCursor(0, -lastLineRows);
47
+ this.stdout.clearScreenDown();
27
48
  }
28
49
 
29
50
  destroy() {
@@ -8,35 +8,45 @@ import ansi from "ansi-styles";
8
8
  import { AbstractPrompt } from "./abstract-prompt.js";
9
9
  import { SYMBOLS } from "./constants.js";
10
10
 
11
- const kDefaultConfirmOptions = { initial: false };
12
-
13
11
  export class ConfirmPrompt extends AbstractPrompt {
14
- #question;
15
-
16
- // eslint-disable-next-line max-params
17
- constructor(message, options = {}, stdin = process.stdin, stdout = process.stdout) {
12
+ constructor(message, options = {}) {
13
+ const {
14
+ stdin = process.stdin,
15
+ stdout = process.stdout,
16
+ initial = false
17
+ } = options;
18
18
  super(message, stdin, stdout);
19
19
 
20
- const { initial } = { ...kDefaultConfirmOptions, ...options };
21
20
  const Yes = `${ansi.bold.open}Yes${ansi.bold.close}`;
22
21
  const No = `${ansi.bold.open}No${ansi.bold.close}`;
23
22
  const tip = initial ? `${Yes}/no` : `yes/${No}`;
24
23
 
25
24
  this.initial = initial;
26
- this.#question = new Promise((resolve) => {
27
- const question = `\
28
- ${ansi.bold.open}${SYMBOLS.QuestionMark} ${message}${ansi.bold.close} ${ansi.grey.open}(${tip})${ansi.grey.close} `;
29
- this.rl.question.bind(this.rl)(question,
25
+ }
26
+
27
+ #question() {
28
+ return new Promise((resolve) => {
29
+ const questionQuery = this.#getQuestionQuery();
30
+
31
+ this.rl.question(questionQuery,
30
32
  (answer) => {
33
+ this.history.push(questionQuery + answer);
34
+
31
35
  resolve(answer);
32
36
  }
33
37
  );
34
38
  });
35
39
  }
36
40
 
41
+ #getQuestionQuery() {
42
+ const query = `${ansi.bold.open}${SYMBOLS.QuestionMark} ${this.message}${ansi.bold.close}`;
43
+
44
+ return `${query} ${ansi.grey.open}(${this.initial ? "Yes/no" : "yes/No"})${ansi.grey.close} `;
45
+ }
46
+
37
47
  #onQuestionAnswer() {
38
48
  this.clearLastLine();
39
- this.stdout.write(`${this.answer ? SYMBOLS.Tick : SYMBOLS.Cross} ${this.message}${EOL}`);
49
+ this.write(`${this.answer ? SYMBOLS.Tick : SYMBOLS.Cross} ${this.message}${EOL}`);
40
50
  }
41
51
 
42
52
  #validateResult(result) {
@@ -49,7 +59,7 @@ export class ConfirmPrompt extends AbstractPrompt {
49
59
 
50
60
  async confirm() {
51
61
  try {
52
- const result = await this.#question;
62
+ const result = await this.#question();
53
63
  this.answer = this.#validateResult(result) ? ["y", "yes"].includes(result.toLocaleLowerCase()) : this.initial;
54
64
  this.#onQuestionAnswer();
55
65
 
@@ -11,8 +11,17 @@ import { SYMBOLS } from "./constants.js";
11
11
  export class SelectPrompt extends AbstractPrompt {
12
12
  activeIndex = 0;
13
13
 
14
- // eslint-disable-next-line max-params
15
- constructor(message, options, stdin = process.stdin, stdout = process.stdout) {
14
+ get choices() {
15
+ return this.options.choices;
16
+ }
17
+
18
+ constructor(message, options) {
19
+ const {
20
+ stdin = process.stdin,
21
+ stdout = process.stdout,
22
+ choices
23
+ } = options ?? {};
24
+
16
25
  super(message, stdin, stdout);
17
26
 
18
27
  if (!options) {
@@ -21,14 +30,12 @@ export class SelectPrompt extends AbstractPrompt {
21
30
  }
22
31
 
23
32
  this.options = options;
24
- const { choices } = options;
25
33
 
26
34
  if (!choices?.length) {
27
35
  this.destroy();
28
36
  throw new TypeError("Missing required param: choices");
29
37
  }
30
38
 
31
- this.choices = choices;
32
39
  this.longestChoice = Math.max(...choices.map((choice) => {
33
40
  if (typeof choice === "string") {
34
41
  return choice.length;
@@ -47,60 +54,92 @@ export class SelectPrompt extends AbstractPrompt {
47
54
  }));
48
55
  }
49
56
 
50
- async select() {
51
- this.stdout.write(SYMBOLS.HideCursor);
52
- this.stdout.write(`${ansi.bold.open}${SYMBOLS.QuestionMark} ${this.message}${ansi.bold.close}${EOL}`);
53
-
54
- let lastRender = null;
55
- const render = (initialRender = false, { reset } = {}) => {
56
- function getVisibleChoices(currentIndex, total, maxVisible) {
57
- let startIndex = Math.min(total - maxVisible, currentIndex - Math.floor(maxVisible / 2));
58
- if (startIndex < 0) {
59
- startIndex = 0;
60
- }
57
+ #getFormattedChoice(choiceIndex) {
58
+ const choice = this.choices[choiceIndex];
61
59
 
62
- const endIndex = Math.min(startIndex + maxVisible, total);
60
+ if (typeof choice === "string") {
61
+ return { value: choice, label: choice };
62
+ }
63
+
64
+ return choice;
65
+ }
66
+
67
+ #getVisibleChoices() {
68
+ const maxVisible = this.options.maxVisible || 8;
69
+ let startIndex = Math.min(this.choices.length - maxVisible, this.activeIndex - Math.floor(maxVisible / 2));
70
+ if (startIndex < 0) {
71
+ startIndex = 0;
72
+ }
63
73
 
64
- return { startIndex, endIndex };
74
+ const endIndex = Math.min(startIndex + maxVisible, this.choices.length);
75
+
76
+ return { startIndex, endIndex };
77
+ }
78
+
79
+ #showChoices() {
80
+ const { startIndex, endIndex } = this.#getVisibleChoices();
81
+ this.lastRender = { startIndex, endIndex };
82
+
83
+ for (let choiceIndex = startIndex; choiceIndex < endIndex; choiceIndex++) {
84
+ const choice = this.#getFormattedChoice(choiceIndex);
85
+ const showPreviousChoicesArrow = startIndex > 0 && choiceIndex === startIndex;
86
+ const showNextChoicesArrow = endIndex < this.choices.length && choiceIndex === endIndex - 1;
87
+
88
+ let prefixArrow = " ";
89
+ if (showPreviousChoicesArrow) {
90
+ prefixArrow = SYMBOLS.Previous;
65
91
  }
92
+ else if (showNextChoicesArrow) {
93
+ prefixArrow = SYMBOLS.Next;
94
+ }
95
+
96
+ const prefix = `${prefixArrow}${choiceIndex === this.activeIndex ? `${SYMBOLS.Active} ` : `${SYMBOLS.Inactive} `}`;
97
+ const formattedLabel = choice.label.padEnd(
98
+ this.longestChoice < 10 ? this.longestChoice : 0
99
+ );
100
+ const formattedDescription = choice.description ? ` - ${choice.description}` : "";
101
+ const str = `${prefix}${formattedLabel}${formattedDescription}${ansi.reset.open}${EOL}`;
66
102
 
67
- const { startIndex, endIndex } = getVisibleChoices(this.activeIndex, this.choices.length, this.options.maxVisible || 8);
103
+ this.write(str);
104
+ }
105
+ }
106
+
107
+ #showAnsweredQuestion(choice) {
108
+ const prefix = `${ansi.bold.open}${SYMBOLS.Tick} ${this.message} ${SYMBOLS.Pointer}`;
109
+ const formattedChoice = `${ansi.yellow.open}${choice.label ?? choice}${ansi.reset.open}`;
110
+
111
+ this.write(`${prefix} ${formattedChoice}${EOL}`);
112
+ }
113
+
114
+ async select() {
115
+ this.write(SYMBOLS.HideCursor);
116
+ this.#showQuestion();
117
+
118
+ const render = (options = {}) => {
119
+ const {
120
+ initialRender = false,
121
+ clearRender = false
122
+ } = options;
68
123
 
69
124
  if (!initialRender) {
70
- const linesToClear = lastRender.endIndex - lastRender.startIndex;
71
- this.stdout.moveCursor(0, -linesToClear);
72
- this.stdout.clearScreenDown();
125
+ let linesToClear = this.lastRender.endIndex - this.lastRender.startIndex;
126
+ while (linesToClear > 0) {
127
+ this.clearLastLine();
128
+ linesToClear--;
129
+ }
73
130
  }
74
131
 
75
- if (reset) {
76
- this.clearLastLine(this.stdout);
77
- this.clearLastLine(this.stdout);
132
+ if (clearRender) {
133
+ this.stdout.moveCursor(0, -2);
134
+ this.stdout.clearScreenDown();
78
135
 
79
136
  return;
80
137
  }
81
138
 
82
- lastRender = { startIndex, endIndex };
83
-
84
- for (let i = startIndex; i < endIndex; i++) {
85
- const choice = typeof this.choices[i] === "string" ? { value: this.choices[i], label: this.choices[i] } : this.choices[i];
86
- const showPreviousChoicesArrow = startIndex > 0 && i === startIndex;
87
- const showNextChoicesArrow = endIndex < this.choices.length && i === endIndex - 1;
88
- let prefixArrow = " ";
89
- if (showPreviousChoicesArrow) {
90
- prefixArrow = SYMBOLS.Previous;
91
- }
92
- else if (showNextChoicesArrow) {
93
- prefixArrow = SYMBOLS.Next;
94
- }
95
- const prefix = `${prefixArrow}${i === this.activeIndex ? `${SYMBOLS.Active} ` : `${SYMBOLS.Inactive} `}`;
96
- const str = `${prefix}${choice.label.padEnd(
97
- this.longestChoice < 10 ? this.longestChoice : 0
98
- )}${choice.description ? ` - ${choice.description}` : ""}${ansi.reset.open}${EOL}`;
99
- this.stdout.write(str);
100
- }
139
+ this.#showChoices();
101
140
  };
102
141
 
103
- render(true);
142
+ render({ initialRender: true });
104
143
 
105
144
  return new Promise((resolve) => {
106
145
  const onKeypress = (value, key) => {
@@ -115,17 +154,18 @@ export class SelectPrompt extends AbstractPrompt {
115
154
  else if (key.name === "return") {
116
155
  this.stdin.off("keypress", onKeypress);
117
156
 
118
- render(false, { reset: true });
157
+ render({ clearRender: true });
158
+
119
159
  const currentChoice = this.choices[this.activeIndex];
120
160
  const value = currentChoice.value ?? currentChoice;
161
+
121
162
  if (!this.options.ignoreValues?.includes(value)) {
122
- const prefix = `${ansi.bold.open}${SYMBOLS.Tick} ${this.message} ${SYMBOLS.Pointer}`;
123
- const choice = `${ansi.yellow.open}${currentChoice.label ?? currentChoice}${ansi.reset.open}`;
124
- this.stdout.write(`${prefix} ${choice}${EOL}`);
163
+ this.#showAnsweredQuestion(currentChoice);
125
164
  }
126
165
 
127
- this.stdout.write(SYMBOLS.ShowCursor);
166
+ this.write(SYMBOLS.ShowCursor);
128
167
  this.destroy();
168
+
129
169
  resolve(value);
130
170
  }
131
171
  };
@@ -133,4 +173,8 @@ export class SelectPrompt extends AbstractPrompt {
133
173
  this.stdin.on("keypress", onKeypress);
134
174
  });
135
175
  }
176
+
177
+ #showQuestion() {
178
+ this.write(`${ansi.bold.open}${SYMBOLS.QuestionMark} ${this.message}${ansi.bold.close}${EOL}`);
179
+ }
136
180
  }
@@ -3,41 +3,81 @@ import { EOL } from "node:os";
3
3
 
4
4
  // Import Third-party Dependencies
5
5
  import ansi from "ansi-styles";
6
+ import stripAnsi from "strip-ansi";
6
7
 
7
8
  // Import Internal Dependencies
8
9
  import { AbstractPrompt } from "./abstract-prompt.js";
9
10
  import { SYMBOLS } from "./constants.js";
10
11
 
11
12
  export class TextPrompt extends AbstractPrompt {
12
- #question;
13
+ #validators;
13
14
 
14
- constructor(message, stdin = process.stdin, stdout = process.stdout) {
15
+ constructor(message, options = {}) {
16
+ const { stdin = process.stdin, stdout = process.stdout, validators = [] } = options;
15
17
  super(message, stdin, stdout);
16
18
 
17
- this.#question = new Promise((resolve) => {
18
- this.rl.question(`${ansi.bold.open}${SYMBOLS.QuestionMark} ${message}${ansi.bold.close} `, (answer) => {
19
+ this.#validators = validators;
20
+ this.questionPrefix = `${ansi.bold.open}${SYMBOLS.QuestionMark} `;
21
+ this.questionSuffix = `${ansi.bold.close} `;
22
+ this.questionSuffixError = "";
23
+ }
24
+
25
+ #question() {
26
+ return new Promise((resolve) => {
27
+ const questionQuery = this.#getQuestionQuery();
28
+
29
+ this.rl.question(questionQuery, (answer) => {
30
+ this.history.push(questionQuery + answer);
31
+
19
32
  resolve(answer);
20
33
  });
21
34
  });
22
35
  }
23
36
 
24
- #onQuestionAnswer() {
25
- this.clearLastLine();
37
+ #getQuestionQuery() {
38
+ return `${this.questionPrefix}${this.message}${this.questionSuffix}${this.questionSuffixError}`;
39
+ }
40
+
41
+ #setQuestionSuffixError(error) {
42
+ const suffix = `${ansi.red.open}[${error}]${ansi.red.close} `;
43
+ this.questionSuffixError = suffix;
44
+ }
45
+
46
+ #writeAnswer() {
26
47
  const prefix = `${ansi.bold.open}${this.answer ? SYMBOLS.Tick : SYMBOLS.Cross}`;
27
48
  const suffix = `${ansi.yellow.close}${ansi.bold.close}${EOL}`;
28
- this.stdout.write(`${prefix} ${this.message} ${SYMBOLS.Pointer} ${ansi.yellow.open}${this.answer ?? ""}${suffix}`);
49
+
50
+ this.write(`${prefix} ${this.message} ${SYMBOLS.Pointer} ${ansi.yellow.open}${this.answer ?? ""}${suffix}`);
29
51
  }
30
52
 
31
- async question() {
32
- try {
33
- this.answer = await this.#question;
53
+ #onQuestionAnswer() {
54
+ this.clearLastLine();
34
55
 
35
- this.#onQuestionAnswer();
56
+ for (const validator of this.#validators) {
57
+ if (!validator.validate(this.answer)) {
58
+ const error = validator.error(this.answer);
59
+ this.#setQuestionSuffixError(error);
60
+ this.answer = this.#question();
36
61
 
37
- return this.answer;
62
+ return;
63
+ }
38
64
  }
39
- finally {
40
- this.destroy();
65
+
66
+ this.#writeAnswer();
67
+ }
68
+
69
+ async question() {
70
+ this.answer = await this.#question();
71
+
72
+ this.#onQuestionAnswer();
73
+
74
+ while (this.answer?.constructor.name === "Promise") {
75
+ this.answer = await this.answer;
76
+ this.#onQuestionAnswer();
41
77
  }
78
+
79
+ this.destroy();
80
+
81
+ return this.answer;
42
82
  }
43
83
  }