Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/calm-trains-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Prompts now have a `userInput` stored separately from their `value`.
38 changes: 19 additions & 19 deletions packages/core/src/prompts/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ function normalisedValue<T>(multiple: boolean, values: T[] | undefined): T | T[]
return values[0];
}

interface AutocompleteOptions<T extends OptionLike> extends PromptOptions<AutocompletePrompt<T>> {
interface AutocompleteOptions<T extends OptionLike>
extends PromptOptions<T['value'] | T['value'][], AutocompletePrompt<T>> {
options: T[];
filter?: FilterFunction<T>;
multiple?: boolean;
}

export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
T['value'] | T['value'][]
> {
options: T[];
filteredOptions: T[];
multiple: boolean;
Expand All @@ -59,30 +62,27 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt {

focusedValue: T['value'] | undefined;
#cursor = 0;
#lastValue: T['value'] | undefined;
#lastUserInput = '';
#filterFn: FilterFunction<T>;

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<T>) {
super({
...opts,
initialValue: undefined,
});
super(opts);

this.options = opts.options;
this.filteredOptions = [...this.options];
Expand All @@ -102,17 +102,17 @@ export default class AutocompletePrompt<T extends OptionLike> 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);
Expand All @@ -124,7 +124,7 @@ export default class AutocompletePrompt<T extends OptionLike> 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 {
Expand Down Expand Up @@ -187,9 +187,9 @@ export default class AutocompletePrompt<T extends OptionLike> 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));
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/prompts/confirm.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { cursor } from 'sisteransi';
import Prompt, { type PromptOptions } from './prompt.js';

interface ConfirmOptions extends PromptOptions<ConfirmPrompt> {
interface ConfirmOptions extends PromptOptions<boolean, ConfirmPrompt> {
active: string;
inactive: string;
initialValue?: boolean;
}
export default class ConfirmPrompt extends Prompt {
export default class ConfirmPrompt extends Prompt<boolean> {
get cursor() {
return this.value ? 0 : 1;
}
Expand All @@ -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;
});

Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/prompts/group-multiselect.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import Prompt, { type PromptOptions } from './prompt.js';

interface GroupMultiSelectOptions<T extends { value: any }>
extends PromptOptions<GroupMultiSelectPrompt<T>> {
extends PromptOptions<T['value'][], GroupMultiSelectPrompt<T>> {
options: Record<string, T[]>;
initialValues?: T['value'][];
required?: boolean;
cursorAt?: T['value'];
selectableGroups?: boolean;
}
export default class GroupMultiSelectPrompt<T extends { value: any }> extends Prompt {
export default class GroupMultiSelectPrompt<T extends { value: any }> extends Prompt<T['value'][]> {
options: (T & { group: string | boolean })[];
cursor = 0;
#selectableGroups: boolean;
Expand All @@ -19,11 +19,18 @@ export default class GroupMultiSelectPrompt<T extends { value: any }> 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);
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/prompts/multi-select.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import Prompt, { type PromptOptions } from './prompt.js';

interface MultiSelectOptions<T extends { value: any }> extends PromptOptions<MultiSelectPrompt<T>> {
interface MultiSelectOptions<T extends { value: any }>
extends PromptOptions<T['value'][], MultiSelectPrompt<T>> {
options: T[];
initialValues?: T['value'][];
required?: boolean;
cursorAt?: T['value'];
}
export default class MultiSelectPrompt<T extends { value: any }> extends Prompt {
export default class MultiSelectPrompt<T extends { value: any }> extends Prompt<T['value'][]> {
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];
}

Expand Down
20 changes: 12 additions & 8 deletions packages/core/src/prompts/password.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,35 @@
import color from 'picocolors';
import Prompt, { type PromptOptions } from './prompt.js';

interface PasswordOptions extends PromptOptions<PasswordPrompt> {
interface PasswordOptions extends PromptOptions<string, PasswordPrompt> {
mask?: string;
}
export default class PasswordPrompt extends Prompt {
export default class PasswordPrompt extends Prompt<string> {
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);
});
}
}
Loading