Skip to content

Commit 94fee2a

Browse files
authored
fix: treat placeholders as visual element (#323)
1 parent 4f6b3c2 commit 94fee2a

File tree

9 files changed

+61
-73
lines changed

9 files changed

+61
-73
lines changed

.changeset/mean-turkeys-help.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": patch
3+
"@clack/core": patch
4+
---
5+
6+
Changes `placeholder` to be a visual hint rather than a tabbable value.

packages/core/src/prompts/autocomplete.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
8787
this.options = opts.options;
8888
this.filteredOptions = [...this.options];
8989
this.multiple = opts.multiple === true;
90-
this._usePlaceholderAsValue = false;
9190
this.#filterFn = opts.filter ?? defaultFilter;
9291
let initialValues: unknown[] | undefined;
9392
if (opts.initialValue && Array.isArray(opts.initialValue)) {

packages/core/src/prompts/prompt.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ import type { Action } from '../utils/index.js';
1212

1313
export interface PromptOptions<Self extends Prompt> {
1414
render(this: Omit<Self, 'prompt'>): string | undefined;
15-
placeholder?: string;
1615
initialValue?: any;
17-
defaultValue?: any;
1816
validate?: ((value: any) => string | Error | undefined) | undefined;
1917
input?: Readable;
2018
output?: Writable;
@@ -34,7 +32,6 @@ export default class Prompt {
3432
private _prevFrame = '';
3533
private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
3634
protected _cursor = 0;
37-
protected _usePlaceholderAsValue = true;
3835

3936
public state: ClackState = 'initial';
4037
public error = '';
@@ -212,25 +209,11 @@ export default class Prompt {
212209
if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) {
213210
this.emit('confirm', char.toLowerCase() === 'y');
214211
}
215-
if (this._usePlaceholderAsValue && char === '\t' && this.opts.placeholder) {
216-
if (!this.value) {
217-
this.rl?.write(this.opts.placeholder);
218-
this._setValue(this.opts.placeholder);
219-
}
220-
}
221212

222213
// Call the key event handler and emit the key event
223214
this.emit('key', char?.toLowerCase(), key);
224215

225216
if (key?.name === 'return') {
226-
if (!this.value) {
227-
if (this.opts.defaultValue) {
228-
this._setValue(this.opts.defaultValue);
229-
} else {
230-
this._setValue('');
231-
}
232-
}
233-
234217
if (this.opts.validate) {
235218
const problem = this.opts.validate(this.value);
236219
if (problem) {

packages/core/test/prompts/prompt.test.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe('Prompt', () => {
3838
const resultPromise = instance.prompt();
3939
input.emit('keypress', '', { name: 'return' });
4040
const result = await resultPromise;
41-
expect(result).to.equal('');
41+
expect(result).to.equal(undefined);
4242
expect(isCancel(result)).to.equal(false);
4343
expect(instance.state).to.equal('submit');
4444
expect(output.buffer).to.deep.equal([cursor.hide, 'foo', '\n', cursor.show]);
@@ -136,37 +136,6 @@ describe('Prompt', () => {
136136
expect(eventFn).toBeCalledWith(false);
137137
});
138138

139-
test('sets value as placeholder on tab if one is set', () => {
140-
const instance = new Prompt({
141-
input,
142-
output,
143-
render: () => 'foo',
144-
placeholder: 'piwa',
145-
});
146-
147-
instance.prompt();
148-
149-
input.emit('keypress', '\t', { name: 'tab' });
150-
151-
expect(instance.value).to.equal('piwa');
152-
});
153-
154-
test('does not set placeholder value on tab if value already set', () => {
155-
const instance = new Prompt({
156-
input,
157-
output,
158-
render: () => 'foo',
159-
placeholder: 'piwa',
160-
initialValue: 'trzy',
161-
});
162-
163-
instance.prompt();
164-
165-
input.emit('keypress', '\t', { name: 'tab' });
166-
167-
expect(instance.value).to.equal('trzy');
168-
});
169-
170139
test('emits key event for unknown chars', () => {
171140
const eventSpy = vi.fn();
172141
const instance = new Prompt({

packages/prompts/src/autocomplete.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ export interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Va
7070
export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
7171
const prompt = new AutocompletePrompt({
7272
options: opts.options,
73-
placeholder: opts.placeholder,
7473
initialValue: opts.initialValue ? [opts.initialValue] : undefined,
7574
filter: (search: string, opt: Option<Value>) => {
7675
return getFilteredOption(search, opt);
@@ -81,6 +80,8 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
8180
// Title and message display
8281
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
8382
const valueAsString = String(this.value ?? '');
83+
const placeholder = opts.placeholder;
84+
const showPlaceholder = valueAsString === '' && placeholder !== undefined;
8485

8586
// Handle different states
8687
switch (this.state) {
@@ -97,7 +98,10 @@ export const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {
9798

9899
default: {
99100
// Display cursor position - show plain text in navigation mode
100-
const searchText = this.isNavigating ? color.dim(valueAsString) : this.valueWithCursor;
101+
const searchText =
102+
this.isNavigating || showPlaceholder
103+
? color.dim(showPlaceholder ? placeholder : valueAsString)
104+
: this.valueWithCursor;
101105

102106
// Show match count if filtered
103107
const matches =
@@ -209,7 +213,6 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
209213
}
210214
return undefined;
211215
},
212-
placeholder: opts.placeholder,
213216
initialValue: opts.initialValues,
214217
input: opts.input,
215218
output: opts.output,
@@ -219,11 +222,14 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
219222

220223
// Selection counter
221224
const value = String(this.value ?? '');
225+
const placeholder = opts.placeholder;
226+
const showPlaceholder = value === '' && placeholder !== undefined;
222227

223228
// Search input display
224-
const searchText = this.isNavigating
225-
? color.dim(value) // Just show plain text when in navigation mode
226-
: this.valueWithCursor;
229+
const searchText =
230+
this.isNavigating || showPlaceholder
231+
? color.dim(showPlaceholder ? placeholder : value) // Just show plain text when in navigation mode
232+
: this.valueWithCursor;
227233

228234
const matches =
229235
this.filteredOptions.length !== opts.options.length

packages/prompts/src/text.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const text = (opts: TextOptions) => {
3131
S_BAR_END
3232
)} ${color.yellow(this.error)}\n`;
3333
case 'submit': {
34-
const displayValue = typeof this.value === 'undefined' ? '' : this.value;
34+
const displayValue = this.value === undefined ? '' : this.value;
3535
return `${title}${color.gray(S_BAR)} ${color.dim(displayValue)}`;
3636
}
3737
case 'cancel':

packages/prompts/test/__snapshots__/autocomplete.test.ts.snap

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,31 @@ exports[`autocomplete > renders initial UI with message and instructions 1`] = `
5151
]
5252
`;
5353
54+
exports[`autocomplete > renders placeholder if set 1`] = `
55+
[
56+
"<cursor.hide>",
57+
"│
58+
◆ Select a fruit
59+
60+
│ Search: Type to search...
61+
│ ● Apple
62+
│ ○ Banana
63+
│ ○ Cherry
64+
│ ○ Grape
65+
│ ○ Orange
66+
│ ↑/↓ to select • Enter: confirm • Type: to search
67+
└",
68+
"<cursor.backward count=999><cursor.up count=10>",
69+
"<cursor.down count=1>",
70+
"<erase.down>",
71+
"◇ Select a fruit
72+
│ Apple",
73+
"
74+
",
75+
"<cursor.show>",
76+
]
77+
`;
78+
5479
exports[`autocomplete > shows hint when option has hint and is focused 1`] = `
5580
[
5681
"<cursor.hide>",

packages/prompts/test/autocomplete.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ describe('autocomplete', () => {
121121
expect(output.buffer).toMatchSnapshot();
122122
});
123123

124+
test('renders placeholder if set', async () => {
125+
const result = autocomplete({
126+
message: 'Select a fruit',
127+
placeholder: 'Type to search...',
128+
options: testOptions,
129+
input,
130+
output,
131+
});
132+
133+
input.emit('keypress', '', { name: 'return' });
134+
const value = await result;
135+
expect(output.buffer).toMatchSnapshot();
136+
expect(value).toBe('apple');
137+
});
138+
124139
test('supports initialValue', async () => {
125140
const result = autocomplete({
126141
message: 'Select a fruit',
@@ -132,6 +147,7 @@ describe('autocomplete', () => {
132147

133148
input.emit('keypress', '', { name: 'return' });
134149
const value = await result;
150+
135151
expect(value).toBe('cherry');
136152
expect(output.buffer).toMatchSnapshot();
137153
});

packages/prompts/test/text.test.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,6 @@ describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => {
5555
expect(value).toBe('');
5656
});
5757

58-
test('<tab> applies placeholder', async () => {
59-
const result = prompts.text({
60-
message: 'foo',
61-
placeholder: 'bar',
62-
input,
63-
output,
64-
});
65-
66-
input.emit('keypress', '\t', { name: 'tab' });
67-
input.emit('keypress', '', { name: 'return' });
68-
69-
const value = await result;
70-
71-
expect(value).toBe('bar');
72-
});
73-
7458
test('can cancel', async () => {
7559
const result = prompts.text({
7660
message: 'foo',

0 commit comments

Comments
 (0)