Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cool-rats-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clack/prompts': minor
'@clack/core': minor
---

Allow `async` validation, add new `validate` state while validation is pending
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect';
export { default as MultiSelectPrompt } from './prompts/multi-select';
export { default as PasswordPrompt } from './prompts/password';
export { default as Prompt, isCancel } from './prompts/prompt';
export type { State } from './prompts/prompt';
export type { State, Validator } from './prompts/prompt';
export { default as SelectPrompt } from './prompts/select';
export { default as SelectKeyPrompt } from './prompts/select-key';
export { default as TextPrompt } from './prompts/text';
Expand Down
23 changes: 19 additions & 4 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { stdin, stdout } from 'node:process';
import readline from 'node:readline';
import { Readable, Writable } from 'node:stream';
import { WriteStream } from 'node:tty';
import { setTimeout } from 'node:timers/promises';
import { cursor, erase } from 'sisteransi';
import wrap from 'wrap-ansi';

Expand Down Expand Up @@ -38,17 +39,21 @@ const aliases = new Map([
]);
const keys = new Set(['up', 'down', 'left', 'right', 'space', 'enter']);

export interface Validator<Value = any> {
(value: Value): string | void | Promise<string | void>;
}

export interface PromptOptions<Self extends Prompt> {
render(this: Omit<Self, 'prompt'>): string | void;
placeholder?: string;
initialValue?: any;
validate?: ((value: any) => string | void) | undefined;
validate?: Validator;
input?: Readable;
output?: Writable;
debug?: boolean;
}

export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error';
export type State = 'initial' | 'active' | 'cancel' | 'validate' | 'submit' | 'error';

export default class Prompt {
protected input: Readable;
Expand Down Expand Up @@ -153,7 +158,7 @@ export default class Prompt {
this.subscribers.clear();
}

private onKeypress(char: string, key?: Key) {
private async onKeypress(char: string, key?: Key) {
if (this.state === 'error') {
this.state = 'active';
}
Expand All @@ -172,7 +177,17 @@ export default class Prompt {

if (key?.name === 'return') {
if (this.opts.validate) {
const problem = this.opts.validate(this.value);
this.state = 'validate';
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the race/timeout is sorted, shouldn't this move closer to the render? No need to set it if never rendered?

let problem = this.opts.validate(this.value);
// Only trigger validation state after 300ms.
// If problem resolves first, render will be cancelled.
await Promise.race([
problem,
setTimeout(300).then(() => {
Comment thread
ulken marked this conversation as resolved.
Outdated
this.render();
}),
]);
problem = await problem;
if (problem) {
this.error = problem;
this.state = 'error';
Expand Down
16 changes: 14 additions & 2 deletions packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SelectPrompt,
State,
TextPrompt,
Validator,
} from '@clack/core';
import isUnicodeSupported from 'is-unicode-supported';
import color from 'picocolors';
Expand All @@ -19,6 +20,7 @@ export { isCancel } from '@clack/core';
const unicode = isUnicodeSupported();
const s = (c: string, fallback: string) => (unicode ? c : fallback);
const S_STEP_ACTIVE = s('◆', '*');
const S_STEP_VALIDATE = S_STEP_ACTIVE;
const S_STEP_CANCEL = s('■', 'x');
const S_STEP_ERROR = s('▲', 'x');
const S_STEP_SUBMIT = s('◇', 'o');
Expand Down Expand Up @@ -49,6 +51,8 @@ const symbol = (state: State) => {
case 'initial':
case 'active':
return color.cyan(S_STEP_ACTIVE);
case 'validate':
return color.cyan(S_STEP_VALIDATE);
case 'cancel':
return color.red(S_STEP_CANCEL);
case 'error':
Expand All @@ -63,7 +67,7 @@ export interface TextOptions {
placeholder?: string;
defaultValue?: string;
initialValue?: string;
validate?: (value: string) => string | void;
validate?: Validator<string>;
}
export const text = (opts: TextOptions) => {
return new TextPrompt({
Expand All @@ -79,6 +83,10 @@ export const text = (opts: TextOptions) => {
const value = !this.value ? placeholder : this.valueWithCursor;

switch (this.state) {
case 'validate':
return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)} ${color.dim(
'Validating...'
)}\n`;
case 'error':
return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow(
S_BAR_END
Expand All @@ -99,7 +107,7 @@ export const text = (opts: TextOptions) => {
export interface PasswordOptions {
message: string;
mask?: string;
validate?: (value: string) => string | void;
validate?: Validator<string>;
}
export const password = (opts: PasswordOptions) => {
return new PasswordPrompt({
Expand All @@ -111,6 +119,10 @@ export const password = (opts: PasswordOptions) => {
const masked = this.masked;

switch (this.state) {
case 'validate':
return `${title}${color.cyan(S_BAR)} ${masked}\n${color.cyan(S_BAR_END)} ${color.dim(
'Validating...'
)}\n`;
case 'error':
return `${title.trim()}\n${color.yellow(S_BAR)} ${masked}\n${color.yellow(
S_BAR_END
Expand Down