Skip to content

Commit f2c2b89

Browse files
dreyfus9243081j
andauthored
feat(core + prompts): adds autocomplete (#288)
Co-authored-by: James Garbutt <[email protected]>
1 parent 2048eb1 commit f2c2b89

File tree

16 files changed

+1163
-45
lines changed

16 files changed

+1163
-45
lines changed

.changeset/nasty-parrots-laugh.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+
Adds `AutocompletePrompt` to core with comprehensive tests and implement both `autocomplete` and `autocomplete-multiselect` components in prompts package.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as p from '@clack/prompts';
2+
import color from 'picocolors';
3+
4+
/**
5+
* Example demonstrating the integrated autocomplete multiselect component
6+
* Which combines filtering and selection in a single interface
7+
*/
8+
9+
async function main() {
10+
console.clear();
11+
12+
p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`);
13+
14+
p.note(
15+
`
16+
${color.cyan('Filter and select multiple items in a single interface:')}
17+
- ${color.yellow('Type')} to filter the list in real-time
18+
- Use ${color.yellow('up/down arrows')} to navigate with improved stability
19+
- Press ${color.yellow('Space')} to select/deselect the highlighted item ${color.green('(multiple selections allowed)')}
20+
- Use ${color.yellow('Backspace')} to modify your filter text when searching for different options
21+
- Press ${color.yellow('Enter')} when done selecting all items
22+
- Press ${color.yellow('Ctrl+C')} to cancel
23+
`,
24+
'Instructions'
25+
);
26+
27+
// Frameworks in alphabetical order
28+
const frameworks = [
29+
{ value: 'angular', label: 'Angular', hint: 'Frontend/UI' },
30+
{ value: 'django', label: 'Django', hint: 'Python Backend' },
31+
{ value: 'dotnet', label: '.NET Core', hint: 'C# Backend' },
32+
{ value: 'electron', label: 'Electron', hint: 'Desktop' },
33+
{ value: 'express', label: 'Express', hint: 'Node.js Backend' },
34+
{ value: 'flask', label: 'Flask', hint: 'Python Backend' },
35+
{ value: 'flutter', label: 'Flutter', hint: 'Mobile' },
36+
{ value: 'laravel', label: 'Laravel', hint: 'PHP Backend' },
37+
{ value: 'nestjs', label: 'NestJS', hint: 'Node.js Backend' },
38+
{ value: 'nextjs', label: 'Next.js', hint: 'React Framework' },
39+
{ value: 'nuxt', label: 'Nuxt.js', hint: 'Vue Framework' },
40+
{ value: 'rails', label: 'Ruby on Rails', hint: 'Ruby Backend' },
41+
{ value: 'react', label: 'React', hint: 'Frontend/UI' },
42+
{ value: 'reactnative', label: 'React Native', hint: 'Mobile' },
43+
{ value: 'spring', label: 'Spring Boot', hint: 'Java Backend' },
44+
{ value: 'svelte', label: 'Svelte', hint: 'Frontend/UI' },
45+
{ value: 'tauri', label: 'Tauri', hint: 'Desktop' },
46+
{ value: 'vue', label: 'Vue.js', hint: 'Frontend/UI' },
47+
];
48+
49+
// Use the new integrated autocompleteMultiselect component
50+
const result = await p.autocompleteMultiselect<string>({
51+
message: 'Select frameworks (type to filter)',
52+
options: frameworks,
53+
placeholder: 'Type to filter...',
54+
maxItems: 8,
55+
});
56+
57+
if (p.isCancel(result)) {
58+
p.cancel('Operation cancelled.');
59+
process.exit(0);
60+
}
61+
62+
// Type guard: if not a cancel symbol, result must be a string array
63+
function isStringArray(value: unknown): value is string[] {
64+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
65+
}
66+
67+
// We can now use the type guard to ensure type safety
68+
if (!isStringArray(result)) {
69+
throw new Error('Unexpected result type');
70+
}
71+
72+
const selectedFrameworks = result;
73+
74+
// If no items selected, show a message
75+
if (selectedFrameworks.length === 0) {
76+
p.note('No frameworks were selected', 'Empty Selection');
77+
process.exit(0);
78+
}
79+
80+
// Display selected frameworks with detailed information
81+
p.note(
82+
`You selected ${color.green(selectedFrameworks.length)} frameworks:`,
83+
'Selection Complete'
84+
);
85+
86+
// Show each selected framework with its details
87+
const selectedDetails = selectedFrameworks
88+
.map((value) => {
89+
const framework = frameworks.find((f) => f.value === value);
90+
return framework
91+
? `${color.cyan(framework.label)} ${color.dim(`- ${framework.hint}`)}`
92+
: value;
93+
})
94+
.join('\n');
95+
96+
p.log.message(selectedDetails);
97+
p.outro(`Successfully selected ${color.green(selectedFrameworks.length)} frameworks.`);
98+
}
99+
100+
main().catch(console.error);

examples/basic/autocomplete.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as p from '@clack/prompts';
2+
import color from 'picocolors';
3+
4+
async function main() {
5+
console.clear();
6+
7+
p.intro(`${color.bgCyan(color.black(' Autocomplete Example '))}`);
8+
9+
p.note(
10+
`
11+
${color.cyan('This example demonstrates the type-ahead autocomplete feature:')}
12+
- ${color.yellow('Type')} to filter the list in real-time
13+
- Use ${color.yellow('up/down arrows')} to navigate the filtered results
14+
- Press ${color.yellow('Enter')} to select the highlighted option
15+
- Press ${color.yellow('Ctrl+C')} to cancel
16+
`,
17+
'Instructions'
18+
);
19+
20+
const countries = [
21+
{ value: 'us', label: 'United States', hint: 'NA' },
22+
{ value: 'ca', label: 'Canada', hint: 'NA' },
23+
{ value: 'mx', label: 'Mexico', hint: 'NA' },
24+
{ value: 'br', label: 'Brazil', hint: 'SA' },
25+
{ value: 'ar', label: 'Argentina', hint: 'SA' },
26+
{ value: 'uk', label: 'United Kingdom', hint: 'EU' },
27+
{ value: 'fr', label: 'France', hint: 'EU' },
28+
{ value: 'de', label: 'Germany', hint: 'EU' },
29+
{ value: 'it', label: 'Italy', hint: 'EU' },
30+
{ value: 'es', label: 'Spain', hint: 'EU' },
31+
{ value: 'pt', label: 'Portugal', hint: 'EU' },
32+
{ value: 'ru', label: 'Russia', hint: 'EU/AS' },
33+
{ value: 'cn', label: 'China', hint: 'AS' },
34+
{ value: 'jp', label: 'Japan', hint: 'AS' },
35+
{ value: 'in', label: 'India', hint: 'AS' },
36+
{ value: 'kr', label: 'South Korea', hint: 'AS' },
37+
{ value: 'au', label: 'Australia', hint: 'OC' },
38+
{ value: 'nz', label: 'New Zealand', hint: 'OC' },
39+
{ value: 'za', label: 'South Africa', hint: 'AF' },
40+
{ value: 'eg', label: 'Egypt', hint: 'AF' },
41+
];
42+
43+
const result = await p.autocomplete({
44+
message: 'Select a country',
45+
options: countries,
46+
placeholder: 'Type to search countries...',
47+
maxItems: 8,
48+
});
49+
50+
if (p.isCancel(result)) {
51+
p.cancel('Operation cancelled.');
52+
process.exit(0);
53+
}
54+
55+
const selected = countries.find((c) => c.value === result);
56+
p.outro(`You selected: ${color.cyan(selected?.label)} (${color.yellow(selected?.hint)})`);
57+
}
58+
59+
main().catch(console.error);

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ export { default as Prompt } from './prompts/prompt.js';
99
export { default as SelectPrompt } from './prompts/select.js';
1010
export { default as SelectKeyPrompt } from './prompts/select-key.js';
1111
export { default as TextPrompt } from './prompts/text.js';
12+
export { default as AutocompletePrompt } from './prompts/autocomplete.js';
1213
export { block, isCancel, getColumns } from './utils/index.js';
1314
export { updateSettings, settings } from './utils/settings.js';
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import type { Key } from 'node:readline';
2+
import color from 'picocolors';
3+
import Prompt, { type PromptOptions } from './prompt.js';
4+
5+
interface OptionLike {
6+
value: unknown;
7+
label?: string;
8+
}
9+
10+
type FilterFunction<T extends OptionLike> = (search: string, opt: T) => boolean;
11+
12+
function getCursorForValue<T extends OptionLike>(
13+
selected: T['value'] | undefined,
14+
items: T[]
15+
): number {
16+
if (selected === undefined) {
17+
return 0;
18+
}
19+
20+
const currLength = items.length;
21+
22+
// If filtering changed the available options, update cursor
23+
if (currLength === 0) {
24+
return 0;
25+
}
26+
27+
// Try to maintain the same selected item
28+
const index = items.findIndex((item) => item.value === selected);
29+
return index !== -1 ? index : 0;
30+
}
31+
32+
function defaultFilter<T extends OptionLike>(input: string, option: T): boolean {
33+
const label = option.label ?? String(option.value);
34+
return label.toLowerCase().includes(input.toLowerCase());
35+
}
36+
37+
function normalisedValue<T>(multiple: boolean, values: T[] | undefined): T | T[] | undefined {
38+
if (!values) {
39+
return undefined;
40+
}
41+
if (multiple) {
42+
return values;
43+
}
44+
return values[0];
45+
}
46+
47+
export interface AutocompleteOptions<T extends OptionLike>
48+
extends PromptOptions<AutocompletePrompt<T>> {
49+
options: T[];
50+
filter?: FilterFunction<T>;
51+
multiple?: boolean;
52+
}
53+
54+
export default class AutocompletePrompt<T extends OptionLike> extends Prompt {
55+
options: T[];
56+
filteredOptions: T[];
57+
multiple: boolean;
58+
isNavigating = false;
59+
selectedValues: Array<T['value']> = [];
60+
61+
focusedValue: T['value'] | undefined;
62+
#cursor = 0;
63+
#lastValue: T['value'] | undefined;
64+
#filterFn: FilterFunction<T>;
65+
66+
get cursor(): number {
67+
return this.#cursor;
68+
}
69+
70+
get valueWithCursor() {
71+
if (!this.value) {
72+
return color.inverse(color.hidden('_'));
73+
}
74+
if (this._cursor >= this.value.length) {
75+
return `${this.value}█`;
76+
}
77+
const s1 = this.value.slice(0, this._cursor);
78+
const [s2, ...s3] = this.value.slice(this._cursor);
79+
return `${s1}${color.inverse(s2)}${s3.join('')}`;
80+
}
81+
82+
constructor(opts: AutocompleteOptions<T>) {
83+
super(opts);
84+
85+
this.options = opts.options;
86+
this.filteredOptions = [...this.options];
87+
this.multiple = opts.multiple === true;
88+
this._usePlaceholderAsValue = false;
89+
this.#filterFn = opts.filter ?? defaultFilter;
90+
let initialValues: unknown[] | undefined;
91+
if (opts.initialValue && Array.isArray(opts.initialValue)) {
92+
if (this.multiple) {
93+
initialValues = opts.initialValue;
94+
} else {
95+
initialValues = opts.initialValue.slice(0, 1);
96+
}
97+
}
98+
99+
if (initialValues) {
100+
this.selectedValues = initialValues;
101+
for (const selectedValue of initialValues) {
102+
const selectedIndex = this.options.findIndex((opt) => opt.value === selectedValue);
103+
if (selectedIndex !== -1) {
104+
this.toggleSelected(selectedValue);
105+
this.#cursor = selectedIndex;
106+
this.focusedValue = this.options[this.#cursor]?.value;
107+
}
108+
}
109+
}
110+
111+
this.on('finalize', () => {
112+
if (!this.value) {
113+
this.value = normalisedValue(this.multiple, initialValues);
114+
}
115+
116+
if (this.state === 'submit') {
117+
this.value = normalisedValue(this.multiple, this.selectedValues);
118+
}
119+
});
120+
121+
this.on('key', (char, key) => this.#onKey(char, key));
122+
this.on('value', (value) => this.#onValueChanged(value));
123+
}
124+
125+
protected override _isActionKey(char: string | undefined, key: Key): boolean {
126+
return (
127+
char === '\t' ||
128+
(this.multiple &&
129+
this.isNavigating &&
130+
key.name === 'space' &&
131+
char !== undefined &&
132+
char !== '')
133+
);
134+
}
135+
136+
#onKey(_char: string | undefined, key: Key): void {
137+
const isUpKey = key.name === 'up';
138+
const isDownKey = key.name === 'down';
139+
140+
// Start navigation mode with up/down arrows
141+
if (isUpKey || isDownKey) {
142+
this.#cursor = Math.max(
143+
0,
144+
Math.min(this.#cursor + (isUpKey ? -1 : 1), this.filteredOptions.length - 1)
145+
);
146+
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
147+
if (!this.multiple) {
148+
this.selectedValues = [this.focusedValue];
149+
}
150+
this.isNavigating = true;
151+
} else {
152+
if (
153+
this.multiple &&
154+
this.focusedValue !== undefined &&
155+
(key.name === 'tab' || (this.isNavigating && key.name === 'space'))
156+
) {
157+
this.toggleSelected(this.focusedValue);
158+
} else {
159+
this.isNavigating = false;
160+
}
161+
}
162+
}
163+
164+
toggleSelected(value: T['value']) {
165+
if (this.filteredOptions.length === 0) {
166+
return;
167+
}
168+
169+
if (this.multiple) {
170+
if (this.selectedValues.includes(value)) {
171+
this.selectedValues = this.selectedValues.filter((v) => v !== value);
172+
} else {
173+
this.selectedValues = [...this.selectedValues, value];
174+
}
175+
} else {
176+
this.selectedValues = [value];
177+
}
178+
}
179+
180+
#onValueChanged(value: string | undefined): void {
181+
if (value !== this.#lastValue) {
182+
this.#lastValue = value;
183+
184+
if (value) {
185+
this.filteredOptions = this.options.filter((opt) => this.#filterFn(value, opt));
186+
} else {
187+
this.filteredOptions = [...this.options];
188+
}
189+
this.#cursor = getCursorForValue(this.focusedValue, this.filteredOptions);
190+
this.focusedValue = this.filteredOptions[this.#cursor]?.value;
191+
}
192+
}
193+
}

0 commit comments

Comments
 (0)