diff --git a/.changeset/solid-goats-shop.md b/.changeset/solid-goats-shop.md new file mode 100644 index 00000000..eb1987d6 --- /dev/null +++ b/.changeset/solid-goats-shop.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Add `clearOnError` option to password prompt to automatically clear input when validation fails diff --git a/packages/core/src/prompts/password.ts b/packages/core/src/prompts/password.ts index c27cde8d..a9864577 100644 --- a/packages/core/src/prompts/password.ts +++ b/packages/core/src/prompts/password.ts @@ -25,6 +25,9 @@ export default class PasswordPrompt extends Prompt { const s2 = masked.slice(this.cursor); return `${s1}${color.inverse(s2[0])}${s2.slice(1)}`; } + clear() { + this._clearUserInput(); + } constructor({ mask, ...opts }: PasswordOptions) { super(opts); this._mask = mask ?? '•'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 1803ebc6..f2720f5a 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -186,6 +186,11 @@ export default class Prompt { } } + protected _clearUserInput(): void { + this.rl?.write(null, { ctrl: true, name: 'u' }); + this._setUserInput(''); + } + private onKeypress(char: string | undefined, key: Key) { if (this._track && key.name !== 'return') { if (key.name && this._isActionKey(char, key)) { diff --git a/packages/prompts/src/password.ts b/packages/prompts/src/password.ts index efdc4ef6..8010960b 100644 --- a/packages/prompts/src/password.ts +++ b/packages/prompts/src/password.ts @@ -6,6 +6,7 @@ export interface PasswordOptions extends CommonOptions { message: string; mask?: string; validate?: (value: string | undefined) => string | Error | undefined; + clearOnError?: boolean; } export const password = (opts: PasswordOptions) => { return new PasswordPrompt({ @@ -22,6 +23,9 @@ export const password = (opts: PasswordOptions) => { switch (this.state) { case 'error': { const maskedText = masked ? ` ${masked}` : ''; + if (opts.clearOnError) { + this.clear(); + } return `${title.trim()}\n${color.yellow(S_BAR)}${maskedText}\n${color.yellow( S_BAR_END )} ${color.yellow(this.error)}\n`; diff --git a/packages/prompts/test/__snapshots__/password.test.ts.snap b/packages/prompts/test/__snapshots__/password.test.ts.snap index 1968a456..fbe7c7ca 100644 --- a/packages/prompts/test/__snapshots__/password.test.ts.snap +++ b/packages/prompts/test/__snapshots__/password.test.ts.snap @@ -14,6 +14,49 @@ exports[`password (isCI = false) > can be aborted by a signal 1`] = ` ] `; +exports[`password (isCI = false) > clears input on error when clearOnError is true 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ ▪_", + "", + "", + "", + "", + "▲ foo +│ ▪ +└ Error +", + "", + "", + "", + "◆ foo +│ ▪_ +└ +", + "", + "", + "", + "│ ▪▪_", + "", + "", + "", + "", + "◇ foo +│ ▪▪", + " +", + "", +] +`; + exports[`password (isCI = false) > renders and clears validation errors 1`] = ` [ "", @@ -168,6 +211,49 @@ exports[`password (isCI = true) > can be aborted by a signal 1`] = ` ] `; +exports[`password (isCI = true) > clears input on error when clearOnError is true 1`] = ` +[ + "", + "│ +◆ foo +│ _ +└ +", + "", + "", + "", + "│ ▪_", + "", + "", + "", + "", + "▲ foo +│ ▪ +└ Error +", + "", + "", + "", + "◆ foo +│ ▪_ +└ +", + "", + "", + "", + "│ ▪▪_", + "", + "", + "", + "", + "◇ foo +│ ▪▪", + " +", + "", +] +`; + exports[`password (isCI = true) > renders and clears validation errors 1`] = ` [ "", diff --git a/packages/prompts/test/password.test.ts b/packages/prompts/test/password.test.ts index 9c8c9b7e..3d118f83 100644 --- a/packages/prompts/test/password.test.ts +++ b/packages/prompts/test/password.test.ts @@ -92,8 +92,9 @@ describe.each(['true', 'false'])('password (isCI = %s)', (isCI) => { input.emit('keypress', 'y', { name: 'y' }); input.emit('keypress', '', { name: 'return' }); - await result; + const value = await result; + expect(value).toBe('xy'); expect(output.buffer).toMatchSnapshot(); }); @@ -127,4 +128,25 @@ describe.each(['true', 'false'])('password (isCI = %s)', (isCI) => { expect(prompts.isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('clears input on error when clearOnError is true', async () => { + const result = prompts.password({ + message: 'foo', + input, + output, + validate: (v) => (v === 'yz' ? undefined : 'Error'), + clearOnError: true, + }); + + input.emit('keypress', 'x', { name: 'x' }); + input.emit('keypress', '', { name: 'return' }); + input.emit('keypress', 'y', { name: 'y' }); + input.emit('keypress', 'z', { name: 'z' }); + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(value).toBe('yz'); + expect(output.buffer).toMatchSnapshot(); + }); });