diff --git a/.changeset/calm-trains-camp.md b/.changeset/calm-trains-camp.md new file mode 100644 index 00000000..ac80ee18 --- /dev/null +++ b/.changeset/calm-trains-camp.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Prompts now have a `userInput` stored separately from their `value`. diff --git a/packages/core/src/prompts/autocomplete.ts b/packages/core/src/prompts/autocomplete.ts index 680c4b61..d1701212 100644 --- a/packages/core/src/prompts/autocomplete.ts +++ b/packages/core/src/prompts/autocomplete.ts @@ -44,13 +44,16 @@ function normalisedValue(multiple: boolean, values: T[] | undefined): T | T[] return values[0]; } -interface AutocompleteOptions extends PromptOptions> { +interface AutocompleteOptions + extends PromptOptions> { options: T[]; filter?: FilterFunction; multiple?: boolean; } -export default class AutocompletePrompt extends Prompt { +export default class AutocompletePrompt extends Prompt< + T['value'] | T['value'][] +> { options: T[]; filteredOptions: T[]; multiple: boolean; @@ -59,30 +62,27 @@ export default class AutocompletePrompt extends Prompt { focusedValue: T['value'] | undefined; #cursor = 0; - #lastValue: T['value'] | undefined; + #lastUserInput = ''; #filterFn: FilterFunction; get cursor(): number { return this.#cursor; } - get valueWithCursor() { - if (!this.value) { + get userInputWithCursor() { + if (!this.userInput) { return color.inverse(color.hidden('_')); } - if (this._cursor >= this.value.length) { - return `${this.value}█`; + if (this._cursor >= this.userInput.length) { + return `${this.userInput}█`; } - const s1 = this.value.slice(0, this._cursor); - const [s2, ...s3] = this.value.slice(this._cursor); + const s1 = this.userInput.slice(0, this._cursor); + const [s2, ...s3] = this.userInput.slice(this._cursor); return `${s1}${color.inverse(s2)}${s3.join('')}`; } constructor(opts: AutocompleteOptions) { - super({ - ...opts, - initialValue: undefined, - }); + super(opts); this.options = opts.options; this.filteredOptions = [...this.options]; @@ -102,17 +102,17 @@ export default class AutocompletePrompt extends Prompt { } if (initialValues) { - this.selectedValues = initialValues; for (const selectedValue of initialValues) { const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue); if (selectedIndex !== -1) { this.toggleSelected(selectedValue); this.#cursor = selectedIndex; - this.focusedValue = this.options[this.#cursor]?.value; } } } + this.focusedValue = this.options[this.#cursor]?.value; + this.on('finalize', () => { if (!this.value) { this.value = normalisedValue(this.multiple, initialValues); @@ -124,7 +124,7 @@ export default class AutocompletePrompt extends Prompt { }); this.on('key', (char, key) => this.#onKey(char, key)); - this.on('value', (value) => this.#onValueChanged(value)); + this.on('userInput', (value) => this.#onUserInputChanged(value)); } protected override _isActionKey(char: string | undefined, key: Key): boolean { @@ -187,9 +187,9 @@ export default class AutocompletePrompt extends Prompt { } } - #onValueChanged(value: string | undefined): void { - if (value !== this.#lastValue) { - this.#lastValue = value; + #onUserInputChanged(value: string): void { + if (value !== this.#lastUserInput) { + this.#lastUserInput = value; if (value) { this.filteredOptions = this.options.filter((opt) => this.#filterFn(value, opt)); diff --git a/packages/core/src/prompts/confirm.ts b/packages/core/src/prompts/confirm.ts index d0b54a78..e3967b25 100644 --- a/packages/core/src/prompts/confirm.ts +++ b/packages/core/src/prompts/confirm.ts @@ -1,12 +1,12 @@ import { cursor } from 'sisteransi'; import Prompt, { type PromptOptions } from './prompt.js'; -interface ConfirmOptions extends PromptOptions { +interface ConfirmOptions extends PromptOptions { active: string; inactive: string; initialValue?: boolean; } -export default class ConfirmPrompt extends Prompt { +export default class ConfirmPrompt extends Prompt { get cursor() { return this.value ? 0 : 1; } @@ -19,7 +19,7 @@ export default class ConfirmPrompt extends Prompt { super(opts, false); this.value = !!opts.initialValue; - this.on('value', () => { + this.on('userInput', () => { this.value = this._value; }); diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index c052765d..795b64b9 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -1,14 +1,14 @@ import Prompt, { type PromptOptions } from './prompt.js'; interface GroupMultiSelectOptions - extends PromptOptions> { + extends PromptOptions> { options: Record; initialValues?: T['value'][]; required?: boolean; cursorAt?: T['value']; selectableGroups?: boolean; } -export default class GroupMultiSelectPrompt extends Prompt { +export default class GroupMultiSelectPrompt extends Prompt { options: (T & { group: string | boolean })[]; cursor = 0; #selectableGroups: boolean; @@ -19,11 +19,18 @@ export default class GroupMultiSelectPrompt extends Pr isGroupSelected(group: string) { const items = this.getGroupItems(group); - return items.every((i) => this.value.includes(i.value)); + const value = this.value; + if (value === undefined) { + return false; + } + return items.every((i) => value.includes(i.value)); } private toggleValue() { const item = this.options[this.cursor]; + if (this.value === undefined) { + this.value = []; + } if (item.group === true) { const group = item.value; const groupedItems = this.getGroupItems(group); diff --git a/packages/core/src/prompts/multi-select.ts b/packages/core/src/prompts/multi-select.ts index fe56d62e..bbb392c8 100644 --- a/packages/core/src/prompts/multi-select.ts +++ b/packages/core/src/prompts/multi-select.ts @@ -1,28 +1,32 @@ import Prompt, { type PromptOptions } from './prompt.js'; -interface MultiSelectOptions extends PromptOptions> { +interface MultiSelectOptions + extends PromptOptions> { options: T[]; initialValues?: T['value'][]; required?: boolean; cursorAt?: T['value']; } -export default class MultiSelectPrompt extends Prompt { +export default class MultiSelectPrompt extends Prompt { options: T[]; cursor = 0; - private get _value() { + private get _value(): T['value'] { return this.options[this.cursor].value; } private toggleAll() { - const allSelected = this.value.length === this.options.length; + const allSelected = this.value !== undefined && this.value.length === this.options.length; this.value = allSelected ? [] : this.options.map((v) => v.value); } private toggleValue() { + if (this.value === undefined) { + this.value = []; + } const selected = this.value.includes(this._value); this.value = selected - ? this.value.filter((value: T['value']) => value !== this._value) + ? this.value.filter((value) => value !== this._value) : [...this.value, this._value]; } diff --git a/packages/core/src/prompts/password.ts b/packages/core/src/prompts/password.ts index 5d2afc38..c27cde8d 100644 --- a/packages/core/src/prompts/password.ts +++ b/packages/core/src/prompts/password.ts @@ -1,31 +1,35 @@ import color from 'picocolors'; import Prompt, { type PromptOptions } from './prompt.js'; -interface PasswordOptions extends PromptOptions { +interface PasswordOptions extends PromptOptions { mask?: string; } -export default class PasswordPrompt extends Prompt { +export default class PasswordPrompt extends Prompt { private _mask = '•'; get cursor() { return this._cursor; } get masked() { - return this.value?.replaceAll(/./g, this._mask) ?? ''; + return this.userInput.replaceAll(/./g, this._mask); } - get valueWithCursor() { + get userInputWithCursor() { if (this.state === 'submit' || this.state === 'cancel') { return this.masked; } - const value = this.value ?? ''; - if (this.cursor >= value.length) { + const userInput = this.userInput; + if (this.cursor >= userInput.length) { return `${this.masked}${color.inverse(color.hidden('_'))}`; } - const s1 = this.masked.slice(0, this.cursor); - const s2 = this.masked.slice(this.cursor); + const masked = this.masked; + const s1 = masked.slice(0, this.cursor); + const s2 = masked.slice(this.cursor); return `${s1}${color.inverse(s2[0])}${s2.slice(1)}`; } constructor({ mask, ...opts }: PasswordOptions) { super(opts); this._mask = mask ?? '•'; + this.on('userInput', (input) => { + this._setValue(input); + }); } } diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index bcb0e84c..119d2998 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -10,24 +10,24 @@ import { CANCEL_SYMBOL, diffLines, isActionKey, setRawMode, settings } from '../ import type { ClackEvents, ClackState } from '../types.js'; import type { Action } from '../utils/index.js'; -export interface PromptOptions { +export interface PromptOptions> { render(this: Omit): string | undefined; initialValue?: any; - validate?: ((value: any) => string | Error | undefined) | undefined; + validate?: ((value: TValue | undefined) => string | Error | undefined) | undefined; input?: Readable; output?: Writable; debug?: boolean; signal?: AbortSignal; } -export default class Prompt { +export default class Prompt { protected input: Readable; protected output: Writable; private _abortSignal?: AbortSignal; protected rl: ReadLine | undefined; - private opts: Omit, 'render' | 'input' | 'output'>; - private _render: (context: Omit) => string | undefined; + private opts: Omit>, 'render' | 'input' | 'output'>; + private _render: (context: Omit, 'prompt'>) => string | undefined; private _track = false; private _prevFrame = ''; private _subscribers = new Map any; once?: boolean }[]>(); @@ -35,9 +35,10 @@ export default class Prompt { public state: ClackState = 'initial'; public error = ''; - public value: any; + public value: TValue | undefined; + public userInput = ''; - constructor(options: PromptOptions, trackValue = true) { + constructor(options: PromptOptions>, trackValue = true) { const { input = stdin, output = stdout, render, signal, ...opts } = options; this.opts = opts; @@ -63,9 +64,9 @@ export default class Prompt { * Set a subscriber with opts * @param event - The event name */ - private setSubscriber( + private setSubscriber>( event: T, - opts: { cb: ClackEvents[T]; once?: boolean } + opts: { cb: ClackEvents[T]; once?: boolean } ) { const params = this._subscribers.get(event) ?? []; params.push(opts); @@ -77,7 +78,7 @@ export default class Prompt { * @param event - The event name * @param cb - The callback */ - public on(event: T, cb: ClackEvents[T]) { + public on>(event: T, cb: ClackEvents[T]) { this.setSubscriber(event, { cb }); } @@ -86,7 +87,7 @@ export default class Prompt { * @param event - The event name * @param cb - The callback */ - public once(event: T, cb: ClackEvents[T]) { + public once>(event: T, cb: ClackEvents[T]) { this.setSubscriber(event, { cb, once: true }); } @@ -95,7 +96,10 @@ export default class Prompt { * @param event - The event name * @param data - The data to pass to the callback */ - public emit(event: T, ...data: Parameters) { + public emit>( + event: T, + ...data: Parameters[T]> + ) { const cbs = this._subscribers.get(event) ?? []; const cleanup: (() => void)[] = []; @@ -113,7 +117,7 @@ export default class Prompt { } public prompt() { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { if (this._abortSignal) { if (this._abortSignal.aborted) { this.state = 'cancel'; @@ -140,21 +144,8 @@ export default class Prompt { terminal: true, }); this.rl.prompt(); - if (this.opts.initialValue !== undefined) { - if (this._track) { - this.rl.write(this.opts.initialValue); - } - this._setValue(this.opts.initialValue); - - // Validate initial value if validator exists - if (this.opts.validate) { - const problem = this.opts.validate(this.opts.initialValue); - if (problem) { - this.error = problem instanceof Error ? problem.message : problem; - this.state = 'error'; - } - } - } + + this.emit('beforePrompt'); this.input.on('keypress', this.onKeypress); setRawMode(this.input, true); @@ -181,18 +172,27 @@ export default class Prompt { return char === '\t'; } - protected _setValue(value: unknown): void { + protected _setValue(value: TValue | undefined): void { this.value = value; this.emit('value', this.value); } + protected _setUserInput(value: string | undefined, write?: boolean): void { + this.userInput = value ?? ''; + this.emit('userInput', this.userInput); + if (write && this._track && this.rl) { + this.rl.write(this.userInput); + this._cursor = this.rl.cursor; + } + } + private onKeypress(char: string | undefined, key: Key) { if (this._track && key.name !== 'return') { if (key.name && this._isActionKey(char, key)) { this.rl?.write(null, { ctrl: true, name: 'h' }); } this._cursor = this.rl?.cursor ?? 0; - this._setValue(this.rl?.line); + this._setUserInput(this.rl?.line); } if (this.state === 'error') { @@ -219,7 +219,7 @@ export default class Prompt { if (problem) { this.error = problem instanceof Error ? problem.message : problem; this.state = 'error'; - this.rl?.write(this.value); + this.rl?.write(this.userInput); } } if (this.state !== 'error') { diff --git a/packages/core/src/prompts/select-key.ts b/packages/core/src/prompts/select-key.ts index 689d0e5b..94062279 100644 --- a/packages/core/src/prompts/select-key.ts +++ b/packages/core/src/prompts/select-key.ts @@ -1,9 +1,10 @@ import Prompt, { type PromptOptions } from './prompt.js'; -interface SelectKeyOptions extends PromptOptions> { +interface SelectKeyOptions + extends PromptOptions> { options: T[]; } -export default class SelectKeyPrompt extends Prompt { +export default class SelectKeyPrompt extends Prompt { options: T[]; cursor = 0; @@ -15,7 +16,7 @@ export default class SelectKeyPrompt extends Prompt { this.cursor = Math.max(keys.indexOf(opts.initialValue), 0); this.on('key', (key) => { - if (!keys.includes(key)) return; + if (!key || !keys.includes(key)) return; const value = this.options.find(({ value: [initial] }) => initial?.toLowerCase() === key); if (value) { this.value = value.value; diff --git a/packages/core/src/prompts/select.ts b/packages/core/src/prompts/select.ts index 07fdf6b3..d24928b9 100644 --- a/packages/core/src/prompts/select.ts +++ b/packages/core/src/prompts/select.ts @@ -1,19 +1,20 @@ import Prompt, { type PromptOptions } from './prompt.js'; -interface SelectOptions extends PromptOptions> { +interface SelectOptions + extends PromptOptions> { options: T[]; initialValue?: T['value']; } -export default class SelectPrompt extends Prompt { +export default class SelectPrompt extends Prompt { options: T[]; cursor = 0; - private get _value() { + private get _selectedValue() { return this.options[this.cursor]; } private changeValue() { - this.value = this._value.value; + this.value = this._selectedValue.value; } constructor(opts: SelectOptions) { diff --git a/packages/core/src/prompts/suggestion.ts b/packages/core/src/prompts/suggestion.ts index f06f80f1..ebaac138 100644 --- a/packages/core/src/prompts/suggestion.ts +++ b/packages/core/src/prompts/suggestion.ts @@ -1,14 +1,13 @@ -import color from 'picocolors'; +import type { Key } from 'node:readline'; import type { ValueWithCursorPart } from '../types.js'; import Prompt, { type PromptOptions } from './prompt.js'; -interface SuggestionOptions extends PromptOptions { +interface SuggestionOptions extends PromptOptions { suggest: (value: string) => Array; initialValue: string; } -export default class SuggestionPrompt extends Prompt { - value: string; +export default class SuggestionPrompt extends Prompt { protected suggest: (value: string) => Array; private selectionIndex = 0; private nextItems: Array = []; @@ -16,12 +15,15 @@ export default class SuggestionPrompt extends Prompt { constructor(opts: SuggestionOptions) { super(opts); - this.value = opts.initialValue; this.suggest = opts.suggest; this.getNextItems(); this.selectionIndex = 0; - this._cursor = this.value.length; + this.on('beforePrompt', () => { + if (opts.initialValue !== undefined) { + this._setUserInput(opts.initialValue, true); + } + }); this.on('cursor', (key) => { switch (key) { case 'up': @@ -29,24 +31,34 @@ export default class SuggestionPrompt extends Prompt { 0, this.selectionIndex === 0 ? this.nextItems.length - 1 : this.selectionIndex - 1 ); + this.value = this.nextItems[this.selectionIndex]; break; case 'down': this.selectionIndex = this.nextItems.length === 0 ? 0 : (this.selectionIndex + 1) % this.nextItems.length; + this.value = this.nextItems[this.selectionIndex]; break; } }); - this.on('key', (key, info) => { - if (info.name === 'tab' && this.nextItems.length > 0) { - const delta = this.nextItems[this.selectionIndex].substring(this.value.length); - this.value = this.nextItems[this.selectionIndex]; + this.on('key', (_key, info) => { + const nextItem = this.nextItems[this.selectionIndex]; + if (info.name === 'tab' && nextItem !== undefined) { + const delta = nextItem.substring(this.userInput.length); + // TODO (43081j): this means the selected value won't show up until we + // later choose another value. probably shouldn't set `value` until + // finalize tbh + this.value = nextItem; this.rl?.write(delta); - this._cursor = this.value.length; + this._cursor = this.rl?.cursor ?? 0; this.selectionIndex = 0; - this.getNextItems(); + this._setUserInput(this.userInput + delta); } }); - this.on('value', () => { + this.on('userInput', () => { + if (this.value !== this.userInput) { + this.value = this.userInput; + } + this.getNextItems(); }); } @@ -55,16 +67,16 @@ export default class SuggestionPrompt extends Prompt { const result: Array = []; if (this._cursor > 0) { result.push({ - text: this.value.substring(0, this._cursor), + text: this.userInput.substring(0, this._cursor), type: 'value', }); } - if (this._cursor < this.value.length) { + if (this._cursor < this.userInput.length) { result.push({ - text: this.value.substring(this._cursor, this._cursor + 1), + text: this.userInput.substring(this._cursor, this._cursor + 1), type: 'cursor_on_value', }); - const left = this.value.substring(this._cursor + 1); + const left = this.userInput.substring(this._cursor + 1); if (left.length > 0) { result.push({ text: left, @@ -100,12 +112,12 @@ export default class SuggestionPrompt extends Prompt { } get suggestion(): string { - return this.nextItems[this.selectionIndex]?.substring(this.value.length) ?? ''; + return this.nextItems[this.selectionIndex]?.substring(this.userInput.length) ?? ''; } private getNextItems(): void { - this.nextItems = this.suggest(this.value).filter((item) => { - return item.startsWith(this.value) && item !== this.value; + this.nextItems = this.suggest(this.userInput).filter((item) => { + return item.startsWith(this.userInput) && item !== this.value; }); if (this.selectionIndex > this.nextItems.length) { this.selectionIndex = 0; diff --git a/packages/core/src/prompts/text.ts b/packages/core/src/prompts/text.ts index 052a9bdb..057d7e5f 100644 --- a/packages/core/src/prompts/text.ts +++ b/packages/core/src/prompts/text.ts @@ -1,22 +1,22 @@ import color from 'picocolors'; import Prompt, { type PromptOptions } from './prompt.js'; -interface TextOptions extends PromptOptions { +interface TextOptions extends PromptOptions { placeholder?: string; defaultValue?: string; } -export default class TextPrompt extends Prompt { - get valueWithCursor() { +export default class TextPrompt extends Prompt { + get userInputWithCursor() { if (this.state === 'submit') { - return this.value; + return this.userInput; } - const value = this.value ?? ''; - if (this.cursor >= value.length) { - return `${this.value}█`; + const userInput = this.userInput; + if (this.cursor >= userInput.length) { + return `${this.userInput}█`; } - const s1 = value.slice(0, this.cursor); - const [s2, ...s3] = value.slice(this.cursor); + const s1 = userInput.slice(0, this.cursor); + const [s2, ...s3] = userInput.slice(this.cursor); return `${s1}${color.inverse(s2)}${s3.join('')}`; } get cursor() { @@ -25,6 +25,14 @@ export default class TextPrompt extends Prompt { constructor(opts: TextOptions) { super(opts); + this.on('beforePrompt', () => { + if (opts.initialValue !== undefined) { + this._setUserInput(opts.initialValue, true); + } + }); + this.on('userInput', (input) => { + this._setValue(input); + }); this.on('finalize', () => { if (!this.value) { this.value = opts.defaultValue; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 27397f17..40f2c0c6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,7 +9,7 @@ export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error'; /** * Typed event emitter for clack */ -export interface ClackEvents { +export interface ClackEvents { initial: (value?: any) => void; active: (value?: any) => void; cancel: (value?: any) => void; @@ -17,9 +17,11 @@ export interface ClackEvents { error: (value?: any) => void; cursor: (key?: Action) => void; key: (key: string | undefined, info: Key) => void; - value: (value?: string) => void; + value: (value?: TValue) => void; + userInput: (value: string) => void; confirm: (value?: boolean) => void; finalize: () => void; + beforePrompt: () => void; } /** diff --git a/packages/core/test/prompts/autocomplete.test.ts b/packages/core/test/prompts/autocomplete.test.ts index eeaffb09..f51ed05b 100644 --- a/packages/core/test/prompts/autocomplete.test.ts +++ b/packages/core/test/prompts/autocomplete.test.ts @@ -118,7 +118,7 @@ describe('AutocompletePrompt', () => { expect(instance.selectedValues).to.deep.equal([]); }); - test('filtering through value event', () => { + test('filtering through user input', () => { const instance = new AutocompletePrompt({ input, output, @@ -131,8 +131,8 @@ describe('AutocompletePrompt', () => { // Initial state should have all options expect(instance.filteredOptions.length).to.equal(testOptions.length); - // Simulate typing 'a' by emitting value event - instance.emit('value', 'a'); + // Simulate typing 'a' by emitting keypress event + input.emit('keypress', 'a', { name: 'a' }); // Check that filtered options are updated to include options with 'a' expect(instance.filteredOptions.length).to.be.lessThan(testOptions.length); @@ -150,14 +150,17 @@ describe('AutocompletePrompt', () => { options: testOptions, }); - instance.emit('value', 'ap'); + instance.prompt(); + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', 'p', { name: 'p' }); expect(instance.filteredOptions).toEqual([ { value: 'apple', label: 'Apple' }, { value: 'grape', label: 'Grape' }, ]); - instance.emit('value', 'z'); + input.emit('keypress', 'z', { name: 'z' }); expect(instance.filteredOptions).toEqual([]); }); diff --git a/packages/core/test/prompts/password.test.ts b/packages/core/test/prompts/password.test.ts index 3ae4ad89..dc145fc7 100644 --- a/packages/core/test/prompts/password.test.ts +++ b/packages/core/test/prompts/password.test.ts @@ -41,7 +41,7 @@ describe('PasswordPrompt', () => { }); }); - describe('valueWithCursor', () => { + describe('userInputWithCursor', () => { test('returns masked value on submit', () => { const instance = new PasswordPrompt({ input, @@ -54,7 +54,7 @@ describe('PasswordPrompt', () => { input.emit('keypress', keys[i], { name: keys[i] }); } input.emit('keypress', '', { name: 'return' }); - expect(instance.valueWithCursor).to.equal('•••'); + expect(instance.userInputWithCursor).to.equal('•••'); }); test('renders marker at end', () => { @@ -65,7 +65,7 @@ describe('PasswordPrompt', () => { }); instance.prompt(); input.emit('keypress', 'x', { name: 'x' }); - expect(instance.valueWithCursor).to.equal(`•${color.inverse(color.hidden('_'))}`); + expect(instance.userInputWithCursor).to.equal(`•${color.inverse(color.hidden('_'))}`); }); test('renders cursor inside value', () => { @@ -80,7 +80,7 @@ describe('PasswordPrompt', () => { input.emit('keypress', 'z', { name: 'z' }); input.emit('keypress', 'left', { name: 'left' }); input.emit('keypress', 'left', { name: 'left' }); - expect(instance.valueWithCursor).to.equal(`•${color.inverse('•')}•`); + expect(instance.userInputWithCursor).to.equal(`•${color.inverse('•')}•`); }); test('renders custom mask', () => { @@ -92,7 +92,7 @@ describe('PasswordPrompt', () => { }); instance.prompt(); input.emit('keypress', 'x', { name: 'x' }); - expect(instance.valueWithCursor).to.equal(`X${color.inverse(color.hidden('_'))}`); + expect(instance.userInputWithCursor).to.equal(`X${color.inverse(color.hidden('_'))}`); }); }); }); diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index f5439bb3..71d6bb24 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -58,7 +58,7 @@ describe('Prompt', () => { expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]); }); - test('writes initialValue to value', () => { + test('does not write initialValue to value', () => { const eventSpy = vi.fn(); const instance = new Prompt({ input, @@ -68,8 +68,8 @@ describe('Prompt', () => { }); instance.on('value', eventSpy); instance.prompt(); - expect(instance.value).to.equal('bananas'); - expect(eventSpy).toHaveBeenCalled(); + expect(instance.value).to.equal(undefined); + expect(eventSpy).not.toHaveBeenCalled(); }); test('re-renders on resize', () => { @@ -233,7 +233,7 @@ describe('Prompt', () => { expect(instance.state).to.equal('cancel'); }); - test('validates initial value on prompt start', () => { + test('accepts invalid initial value', () => { const instance = new Prompt({ input, output, @@ -243,63 +243,72 @@ describe('Prompt', () => { }); instance.prompt(); - expect(instance.state).to.equal('error'); - expect(instance.error).to.equal('must be valid'); + expect(instance.state).to.equal('active'); + expect(instance.error).to.equal(''); }); - test('accepts valid initial value', () => { + test('validates value on return', () => { const instance = new Prompt({ input, output, render: () => 'foo', - initialValue: 'valid', validate: (value) => (value === 'valid' ? undefined : 'must be valid'), }); instance.prompt(); - expect(instance.state).to.equal('active'); - expect(instance.error).to.equal(''); + instance.value = 'invalid'; + + input.emit('keypress', '', {name: 'return'}); + + expect(instance.state).to.equal('error'); + expect(instance.error).to.equal('must be valid'); }); - test('validates initial value with Error object', () => { + test('validates value with Error object', () => { const instance = new Prompt({ input, output, render: () => 'foo', - initialValue: 'invalid', validate: (value) => (value === 'valid' ? undefined : new Error('must be valid')), }); instance.prompt(); + instance.value = 'invalid'; + input.emit('keypress', '', {name: 'return'}); + expect(instance.state).to.equal('error'); expect(instance.error).to.equal('must be valid'); }); - test('validates initial value with regex validation', () => { - const instance = new Prompt({ + test('validates value with regex validation', () => { + const instance = new Prompt({ input, output, render: () => 'foo', - initialValue: 'Invalid Value $$$', - validate: (value) => (/^[A-Z]+$/.test(value) ? undefined : 'Invalid value'), + validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), }); instance.prompt(); + instance.value = 'Invalid Value $$$'; + input.emit('keypress', '', {name: 'return'}); + expect(instance.state).to.equal('error'); expect(instance.error).to.equal('Invalid value'); }); - test('accepts valid initial value with regex validation', () => { - const instance = new Prompt({ + test('accepts valid value with regex validation', () => { + const instance = new Prompt({ input, output, render: () => 'foo', - initialValue: 'VALID', - validate: (value) => (/^[A-Z]+$/.test(value) ? undefined : 'Invalid value'), + validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), }); instance.prompt(); - expect(instance.state).to.equal('active'); + instance.value = 'VALID'; + input.emit('keypress', '', {name: 'return'}); + + expect(instance.state).to.equal('submit'); expect(instance.error).to.equal(''); }); }); diff --git a/packages/core/test/prompts/text.test.ts b/packages/core/test/prompts/text.test.ts index 27ac8b99..9ae20333 100644 --- a/packages/core/test/prompts/text.test.ts +++ b/packages/core/test/prompts/text.test.ts @@ -68,7 +68,7 @@ describe('TextPrompt', () => { }); }); - describe('valueWithCursor', () => { + describe('userInputWithCursor', () => { test('returns value on submit', () => { const instance = new TextPrompt({ input, @@ -78,7 +78,7 @@ describe('TextPrompt', () => { instance.prompt(); input.emit('keypress', 'x', { name: 'x' }); input.emit('keypress', '', { name: 'return' }); - expect(instance.valueWithCursor).to.equal('x'); + expect(instance.userInputWithCursor).to.equal('x'); }); test('highlights cursor position', () => { @@ -93,7 +93,7 @@ describe('TextPrompt', () => { input.emit('keypress', keys[i], { name: keys[i] }); } input.emit('keypress', 'left', { name: 'left' }); - expect(instance.valueWithCursor).to.equal(`fo${color.inverse('o')}`); + expect(instance.userInputWithCursor).to.equal(`fo${color.inverse('o')}`); }); test('shows cursor at end if beyond value', () => { @@ -108,7 +108,7 @@ describe('TextPrompt', () => { input.emit('keypress', keys[i], { name: keys[i] }); } input.emit('keypress', 'right', { name: 'right' }); - expect(instance.valueWithCursor).to.equal('foo█'); + expect(instance.userInputWithCursor).to.equal('foo█'); }); test('does not use placeholder as value when pressing enter', async () => { diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index ec7db828..698641de 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -79,6 +79,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { render() { // Title and message display const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const userInput = this.userInput; const valueAsString = String(this.value ?? ''); const placeholder = opts.placeholder; const showPlaceholder = valueAsString === '' && placeholder !== undefined; @@ -93,15 +94,15 @@ export const autocomplete = (opts: AutocompleteOptions) => { } case 'cancel': { - return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(this.value ?? ''))}`; + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(userInput))}`; } default: { // Display cursor position - show plain text in navigation mode const searchText = this.isNavigating || showPlaceholder - ? color.dim(showPlaceholder ? placeholder : valueAsString) - : this.valueWithCursor; + ? color.dim(showPlaceholder ? placeholder : userInput) + : this.userInputWithCursor; // Show match count if filtered const matches = @@ -142,7 +143,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { // No matches message const noResults = - this.filteredOptions.length === 0 && valueAsString + this.filteredOptions.length === 0 && userInput ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] : []; @@ -221,15 +222,15 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; // Selection counter - const value = String(this.value ?? ''); + const userInput = this.userInput; const placeholder = opts.placeholder; - const showPlaceholder = value === '' && placeholder !== undefined; + const showPlaceholder = userInput === '' && placeholder !== undefined; // Search input display const searchText = this.isNavigating || showPlaceholder - ? color.dim(showPlaceholder ? placeholder : value) // Just show plain text when in navigation mode - : this.valueWithCursor; + ? color.dim(showPlaceholder ? placeholder : userInput) // Just show plain text when in navigation mode + : this.userInputWithCursor; const matches = this.filteredOptions.length !== opts.options.length @@ -244,7 +245,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti return `${title}${color.gray(S_BAR)} ${color.dim(`${this.selectedValues.length} items selected`)}`; } case 'cancel': { - return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(value))}`; + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(userInput))}`; } default: { // Instructions @@ -257,7 +258,7 @@ export const autocompleteMultiselect = (opts: AutocompleteMultiSelectOpti // No results message const noResults = - this.filteredOptions.length === 0 && value + this.filteredOptions.length === 0 && userInput ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] : []; diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index 66357885..d132f341 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -83,8 +83,8 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => required: opts.required ?? true, cursorAt: opts.cursorAt, selectableGroups, - validate(selected: Value[]) { - if (this.required && selected.length === 0) + validate(selected: Value[] | undefined) { + if (this.required && (selected === undefined || selected.length === 0)) return `Please select at least one option.\n${color.reset( color.dim( `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( @@ -95,17 +95,18 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => }, render() { const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const value = this.value ?? []; switch (this.state) { case 'submit': { return `${title}${color.gray(S_BAR)} ${this.options - .filter(({ value }) => this.value.includes(value)) + .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, 'submitted')) .join(color.dim(', '))}`; } case 'cancel': { const label = this.options - .filter(({ value }) => this.value.includes(value)) + .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, 'cancelled')) .join(color.dim(', ')); return `${title}${color.gray(S_BAR)} ${ @@ -122,7 +123,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => return `${title}${color.yellow(S_BAR)} ${this.options .map((option, i, options) => { const selected = - this.value.includes(option.value) || + value.includes(option.value) || (option.group === true && this.isGroupSelected(`${option.value}`)); const active = i === this.cursor; const groupActive = @@ -146,7 +147,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => return `${title}${color.cyan(S_BAR)} ${this.options .map((option, i, options) => { const selected = - this.value.includes(option.value) || + value.includes(option.value) || (option.group === true && this.isGroupSelected(`${option.value}`)); const active = i === this.cursor; const groupActive = diff --git a/packages/prompts/src/multi-select.ts b/packages/prompts/src/multi-select.ts index a93420f8..661231ed 100644 --- a/packages/prompts/src/multi-select.ts +++ b/packages/prompts/src/multi-select.ts @@ -57,8 +57,8 @@ export const multiselect = (opts: MultiSelectOptions) => { initialValues: opts.initialValues, required: opts.required ?? true, cursorAt: opts.cursorAt, - validate(selected: Value[]) { - if (this.required && selected.length === 0) + validate(selected: Value[] | undefined) { + if (this.required && (selected === undefined || selected.length === 0)) return `Please select at least one option.\n${color.reset( color.dim( `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray( @@ -69,9 +69,10 @@ export const multiselect = (opts: MultiSelectOptions) => { }, render() { const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const value = this.value ?? []; const styleOption = (option: Option, active: boolean) => { - const selected = this.value.includes(option.value); + const selected = value.includes(option.value); if (active && selected) { return opt(option, 'active-selected'); } @@ -85,14 +86,14 @@ export const multiselect = (opts: MultiSelectOptions) => { case 'submit': { return `${title}${color.gray(S_BAR)} ${ this.options - .filter(({ value }) => this.value.includes(value)) + .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, 'submitted')) .join(color.dim(', ')) || color.dim('none') }`; } case 'cancel': { const label = this.options - .filter(({ value }) => this.value.includes(value)) + .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, 'cancelled')) .join(color.dim(', ')); return `${title}${color.gray(S_BAR)} ${ diff --git a/packages/prompts/src/password.ts b/packages/prompts/src/password.ts index 3cf15fc0..83cef569 100644 --- a/packages/prompts/src/password.ts +++ b/packages/prompts/src/password.ts @@ -5,7 +5,7 @@ import { type CommonOptions, S_BAR, S_BAR_END, S_PASSWORD_MASK, symbol } from '. export interface PasswordOptions extends CommonOptions { message: string; mask?: string; - validate?: (value: string) => string | Error | undefined; + validate?: (value: string | undefined) => string | Error | undefined; } export const password = (opts: PasswordOptions) => { return new PasswordPrompt({ @@ -15,7 +15,7 @@ export const password = (opts: PasswordOptions) => { output: opts.output, render() { const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - const value = this.valueWithCursor; + const userInput = this.userInputWithCursor; const masked = this.masked; switch (this.state) { @@ -26,11 +26,11 @@ export const password = (opts: PasswordOptions) => { case 'submit': return `${title}${color.gray(S_BAR)} ${color.dim(masked)}`; case 'cancel': - return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ''))}${ + return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked))}${ masked ? `\n${color.gray(S_BAR)}` : '' }`; default: - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; + return `${title}${color.cyan(S_BAR)} ${userInput}\n${color.cyan(S_BAR_END)}\n`; } }, }).prompt() as Promise; diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts index b1e04b14..567a7bd9 100644 --- a/packages/prompts/src/path.ts +++ b/packages/prompts/src/path.ts @@ -1,7 +1,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { dirname } from 'knip/dist/util/path.js'; -import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; +import type { CommonOptions } from './common.js'; import { suggestion } from './suggestion.js'; export interface PathOptions extends CommonOptions { @@ -9,7 +9,7 @@ export interface PathOptions extends CommonOptions { directory?: boolean; initialValue?: string; message: string; - validate?: (value: string) => string | Error | undefined; + validate?: (value: string | undefined) => string | Error | undefined; } export const path = (opts: PathOptions) => { diff --git a/packages/prompts/src/suggestion.ts b/packages/prompts/src/suggestion.ts index 885524fa..2c669560 100644 --- a/packages/prompts/src/suggestion.ts +++ b/packages/prompts/src/suggestion.ts @@ -5,7 +5,7 @@ import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; export interface SuggestionOptions extends CommonOptions { initialValue?: string; message: string; - validate?: (value: string) => string | Error | undefined; + validate?: (value: string | undefined) => string | Error | undefined; suggest: (value: string) => Array; } diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index 67a303a8..bad4224b 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -7,7 +7,7 @@ export interface TextOptions extends CommonOptions { placeholder?: string; defaultValue?: string; initialValue?: string; - validate?: (value: string) => string | Error | undefined; + validate?: (value: string | undefined) => string | Error | undefined; } export const text = (opts: TextOptions) => { @@ -23,23 +23,23 @@ export const text = (opts: TextOptions) => { const placeholder = opts.placeholder ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1)) : color.inverse(color.hidden('_')); - const value = !this.value ? placeholder : this.valueWithCursor; + const userInput = !this.userInput ? placeholder : this.userInputWithCursor; + const value = this.value ?? ''; switch (this.state) { case 'error': - return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( + return `${title.trim()}\n${color.yellow(S_BAR)} ${userInput}\n${color.yellow( S_BAR_END )} ${color.yellow(this.error)}\n`; case 'submit': { - const displayValue = this.value === undefined ? '' : this.value; - return `${title}${color.gray(S_BAR)} ${color.dim(displayValue)}`; + return `${title}${color.gray(S_BAR)} ${color.dim(value)}`; } case 'cancel': return `${title}${color.gray(S_BAR)} ${color.strikethrough( - color.dim(this.value ?? '') - )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`; + color.dim(value) + )}${value.trim() ? `\n${color.gray(S_BAR)}` : ''}`; default: - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; + return `${title}${color.cyan(S_BAR)} ${userInput}\n${color.cyan(S_BAR_END)}\n`; } }, }).prompt() as Promise; diff --git a/packages/prompts/test/__snapshots__/path.test.ts.snap b/packages/prompts/test/__snapshots__/path.test.ts.snap index 27945694..ce1138df 100644 --- a/packages/prompts/test/__snapshots__/path.test.ts.snap +++ b/packages/prompts/test/__snapshots__/path.test.ts.snap @@ -143,21 +143,17 @@ exports[`text (isCI = false) > renders submitted value 1`] = ` exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = ` [ - "", - "", - "", + "", "│ -▲ foo -│ /tmp/foo -└ should be /tmp/bar -", - "", - "", - "", - "◆ foo -│ /tmp/b  +◆ foo +│ /tmp/foo └ ", + "", + "", + "", + "│ /tmp/b ", + "", "", "", "", @@ -190,21 +186,17 @@ exports[`text (isCI = false) > validation errors render and clear (using Error) exports[`text (isCI = false) > validation errors render and clear 1`] = ` [ - "", - "", - "", + "", "│ -▲ foo -│ /tmp/foo -└ should be /tmp/bar -", - "", - "", - "", - "◆ foo -│ /tmp/b  +◆ foo +│ /tmp/foo └ ", + "", + "", + "", + "│ /tmp/b ", + "", "", "", "", @@ -378,21 +370,17 @@ exports[`text (isCI = true) > renders submitted value 1`] = ` exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = ` [ - "", - "", - "", + "", "│ -▲ foo -│ /tmp/foo -└ should be /tmp/bar -", - "", - "", - "", - "◆ foo -│ /tmp/b  +◆ foo +│ /tmp/foo └ ", + "", + "", + "", + "│ /tmp/b ", + "", "", "", "", @@ -425,21 +413,17 @@ exports[`text (isCI = true) > validation errors render and clear (using Error) 1 exports[`text (isCI = true) > validation errors render and clear 1`] = ` [ - "", - "", - "", + "", "│ -▲ foo -│ /tmp/foo -└ should be /tmp/bar -", - "", - "", - "", - "◆ foo -│ /tmp/b  +◆ foo +│ /tmp/foo └ ", + "", + "", + "", + "│ /tmp/b ", + "", "", "", "", diff --git a/packages/prompts/test/__snapshots__/suggestion.test.ts.snap b/packages/prompts/test/__snapshots__/suggestion.test.ts.snap index 93446c2d..e1f2aeeb 100644 --- a/packages/prompts/test/__snapshots__/suggestion.test.ts.snap +++ b/packages/prompts/test/__snapshots__/suggestion.test.ts.snap @@ -142,21 +142,17 @@ exports[`text (isCI = false) > renders submitted value 1`] = ` exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = ` [ - "", - "", - "", + "", "│ -▲ foo -│ xyz -└ should be xy -", - "", - "", - "", - "◆ foo -│ xyz +◆ foo +│ xyz └ ", + "", + "", + "", + "│ xyz", + "", "", "", "", @@ -184,21 +180,17 @@ exports[`text (isCI = false) > validation errors render and clear (using Error) exports[`text (isCI = false) > validation errors render and clear 1`] = ` [ - "", - "", - "", + "", "│ -▲ foo -│ xyz -└ should be xy -", - "", - "", - "", - "◆ foo -│ xyz +◆ foo +│ xyz └ ", + "", + "", + "", + "│ xyz", + "", "", "", "", @@ -366,21 +358,17 @@ exports[`text (isCI = true) > renders submitted value 1`] = ` exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = ` [ - "", - "", - "", + "", "│ -▲ foo -│ xyz -└ should be xy -", - "", - "", - "", - "◆ foo -│ xyz +◆ foo +│ xyz └ ", + "", + "", + "", + "│ xyz", + "", "", "", "", @@ -408,21 +396,17 @@ exports[`text (isCI = true) > validation errors render and clear (using Error) 1 exports[`text (isCI = true) > validation errors render and clear 1`] = ` [ - "", - "", - "", + "", "│ -▲ foo -│ xyz -└ should be xy -", - "", - "", - "", - "◆ foo -│ xyz +◆ foo +│ xyz └ ", + "", + "", + "", + "│ xyz", + "", "", "", "", diff --git a/packages/prompts/test/path.test.ts b/packages/prompts/test/path.test.ts index 52df1d8b..c7f0e208 100644 --- a/packages/prompts/test/path.test.ts +++ b/packages/prompts/test/path.test.ts @@ -1,4 +1,4 @@ -import { fs, vol } from 'memfs'; +import { vol } from 'memfs'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import * as prompts from '../src/index.js'; import { MockReadable, MockWritable } from './test-utils.js';