Skip to content

Commit 7bc3301

Browse files
authored
feat: rework values and user input to be separate (#334)
1 parent 675bfd6 commit 7bc3301

26 files changed

+303
-275
lines changed

.changeset/calm-trains-camp.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": minor
3+
"@clack/core": minor
4+
---
5+
6+
Prompts now have a `userInput` stored separately from their `value`.

packages/core/src/prompts/autocomplete.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,16 @@ function normalisedValue<T>(multiple: boolean, values: T[] | undefined): T | T[]
4444
return values[0];
4545
}
4646

47-
interface AutocompleteOptions<T extends OptionLike> extends PromptOptions<AutocompletePrompt<T>> {
47+
interface AutocompleteOptions<T extends OptionLike>
48+
extends PromptOptions<T['value'] | T['value'][], AutocompletePrompt<T>> {
4849
options: T[];
4950
filter?: FilterFunction<T>;
5051
multiple?: boolean;
5152
}
5253

53-
export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
54+
export default class AutocompletePrompt<T extends OptionLike> extends Prompt<
55+
T['value'] | T['value'][]
56+
> {
5457
options: T[];
5558
filteredOptions: T[];
5659
multiple: boolean;
@@ -59,30 +62,27 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
5962

6063
focusedValue: T['value'] | undefined;
6164
#cursor = 0;
62-
#lastValue: T['value'] | undefined;
65+
#lastUserInput = '';
6366
#filterFn: FilterFunction<T>;
6467

6568
get cursor(): number {
6669
return this.#cursor;
6770
}
6871

69-
get valueWithCursor() {
70-
if (!this.value) {
72+
get userInputWithCursor() {
73+
if (!this.userInput) {
7174
return color.inverse(color.hidden('_'));
7275
}
73-
if (this._cursor >= this.value.length) {
74-
return `${this.value}█`;
76+
if (this._cursor >= this.userInput.length) {
77+
return `${this.userInput}█`;
7578
}
76-
const s1 = this.value.slice(0, this._cursor);
77-
const [s2, ...s3] = this.value.slice(this._cursor);
79+
const s1 = this.userInput.slice(0, this._cursor);
80+
const [s2, ...s3] = this.userInput.slice(this._cursor);
7881
return `${s1}${color.inverse(s2)}${s3.join('')}`;
7982
}
8083

8184
constructor(opts: AutocompleteOptions<T>) {
82-
super({
83-
...opts,
84-
initialValue: undefined,
85-
});
85+
super(opts);
8686

8787
this.options = opts.options;
8888
this.filteredOptions = [...this.options];
@@ -102,17 +102,17 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
102102
}
103103

104104
if (initialValues) {
105-
this.selectedValues = initialValues;
106105
for (const selectedValue of initialValues) {
107106
const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue);
108107
if (selectedIndex !== -1) {
109108
this.toggleSelected(selectedValue);
110109
this.#cursor = selectedIndex;
111-
this.focusedValue = this.options[this.#cursor]?.value;
112110
}
113111
}
114112
}
115113

114+
this.focusedValue = this.options[this.#cursor]?.value;
115+
116116
this.on('finalize', () => {
117117
if (!this.value) {
118118
this.value = normalisedValue(this.multiple, initialValues);
@@ -124,7 +124,7 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
124124
});
125125

126126
this.on('key', (char, key) => this.#onKey(char, key));
127-
this.on('value', (value) => this.#onValueChanged(value));
127+
this.on('userInput', (value) => this.#onUserInputChanged(value));
128128
}
129129

130130
protected override _isActionKey(char: string | undefined, key: Key): boolean {
@@ -187,9 +187,9 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
187187
}
188188
}
189189

190-
#onValueChanged(value: string | undefined): void {
191-
if (value !== this.#lastValue) {
192-
this.#lastValue = value;
190+
#onUserInputChanged(value: string): void {
191+
if (value !== this.#lastUserInput) {
192+
this.#lastUserInput = value;
193193

194194
if (value) {
195195
this.filteredOptions = this.options.filter((opt) => this.#filterFn(value, opt));

packages/core/src/prompts/confirm.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { cursor } from 'sisteransi';
22
import Prompt, { type PromptOptions } from './prompt.js';
33

4-
interface ConfirmOptions extends PromptOptions<ConfirmPrompt> {
4+
interface ConfirmOptions extends PromptOptions<boolean, ConfirmPrompt> {
55
active: string;
66
inactive: string;
77
initialValue?: boolean;
88
}
9-
export default class ConfirmPrompt extends Prompt {
9+
export default class ConfirmPrompt extends Prompt<boolean> {
1010
get cursor() {
1111
return this.value ? 0 : 1;
1212
}
@@ -19,7 +19,7 @@ export default class ConfirmPrompt extends Prompt {
1919
super(opts, false);
2020
this.value = !!opts.initialValue;
2121

22-
this.on('value', () => {
22+
this.on('userInput', () => {
2323
this.value = this._value;
2424
});
2525

packages/core/src/prompts/group-multiselect.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import Prompt, { type PromptOptions } from './prompt.js';
22

33
interface GroupMultiSelectOptions<T extends { value: any }>
4-
extends PromptOptions<GroupMultiSelectPrompt<T>> {
4+
extends PromptOptions<T['value'][], GroupMultiSelectPrompt<T>> {
55
options: Record<string, T[]>;
66
initialValues?: T['value'][];
77
required?: boolean;
88
cursorAt?: T['value'];
99
selectableGroups?: boolean;
1010
}
11-
export default class GroupMultiSelectPrompt<T extends { value: any }> extends Prompt {
11+
export default class GroupMultiSelectPrompt<T extends { value: any }> extends Prompt<T['value'][]> {
1212
options: (T & { group: string | boolean })[];
1313
cursor = 0;
1414
#selectableGroups: boolean;
@@ -19,11 +19,18 @@ export default class GroupMultiSelectPrompt<T extends { value: any }> extends Pr
1919

2020
isGroupSelected(group: string) {
2121
const items = this.getGroupItems(group);
22-
return items.every((i) => this.value.includes(i.value));
22+
const value = this.value;
23+
if (value === undefined) {
24+
return false;
25+
}
26+
return items.every((i) => value.includes(i.value));
2327
}
2428

2529
private toggleValue() {
2630
const item = this.options[this.cursor];
31+
if (this.value === undefined) {
32+
this.value = [];
33+
}
2734
if (item.group === true) {
2835
const group = item.value;
2936
const groupedItems = this.getGroupItems(group);

packages/core/src/prompts/multi-select.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
11
import Prompt, { type PromptOptions } from './prompt.js';
22

3-
interface MultiSelectOptions<T extends { value: any }> extends PromptOptions<MultiSelectPrompt<T>> {
3+
interface MultiSelectOptions<T extends { value: any }>
4+
extends PromptOptions<T['value'][], MultiSelectPrompt<T>> {
45
options: T[];
56
initialValues?: T['value'][];
67
required?: boolean;
78
cursorAt?: T['value'];
89
}
9-
export default class MultiSelectPrompt<T extends { value: any }> extends Prompt {
10+
export default class MultiSelectPrompt<T extends { value: any }> extends Prompt<T['value'][]> {
1011
options: T[];
1112
cursor = 0;
1213

13-
private get _value() {
14+
private get _value(): T['value'] {
1415
return this.options[this.cursor].value;
1516
}
1617

1718
private toggleAll() {
18-
const allSelected = this.value.length === this.options.length;
19+
const allSelected = this.value !== undefined && this.value.length === this.options.length;
1920
this.value = allSelected ? [] : this.options.map((v) => v.value);
2021
}
2122

2223
private toggleValue() {
24+
if (this.value === undefined) {
25+
this.value = [];
26+
}
2327
const selected = this.value.includes(this._value);
2428
this.value = selected
25-
? this.value.filter((value: T['value']) => value !== this._value)
29+
? this.value.filter((value) => value !== this._value)
2630
: [...this.value, this._value];
2731
}
2832

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,35 @@
11
import color from 'picocolors';
22
import Prompt, { type PromptOptions } from './prompt.js';
33

4-
interface PasswordOptions extends PromptOptions<PasswordPrompt> {
4+
interface PasswordOptions extends PromptOptions<string, PasswordPrompt> {
55
mask?: string;
66
}
7-
export default class PasswordPrompt extends Prompt {
7+
export default class PasswordPrompt extends Prompt<string> {
88
private _mask = '•';
99
get cursor() {
1010
return this._cursor;
1111
}
1212
get masked() {
13-
return this.value?.replaceAll(/./g, this._mask) ?? '';
13+
return this.userInput.replaceAll(/./g, this._mask);
1414
}
15-
get valueWithCursor() {
15+
get userInputWithCursor() {
1616
if (this.state === 'submit' || this.state === 'cancel') {
1717
return this.masked;
1818
}
19-
const value = this.value ?? '';
20-
if (this.cursor >= value.length) {
19+
const userInput = this.userInput;
20+
if (this.cursor >= userInput.length) {
2121
return `${this.masked}${color.inverse(color.hidden('_'))}`;
2222
}
23-
const s1 = this.masked.slice(0, this.cursor);
24-
const s2 = this.masked.slice(this.cursor);
23+
const masked = this.masked;
24+
const s1 = masked.slice(0, this.cursor);
25+
const s2 = masked.slice(this.cursor);
2526
return `${s1}${color.inverse(s2[0])}${s2.slice(1)}`;
2627
}
2728
constructor({ mask, ...opts }: PasswordOptions) {
2829
super(opts);
2930
this._mask = mask ?? '•';
31+
this.on('userInput', (input) => {
32+
this._setValue(input);
33+
});
3034
}
3135
}

0 commit comments

Comments
 (0)