Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/nasty-parrots-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Adds `AutocompletePrompt` to core with comprehensive tests and implement both `autocomplete` and `autocomplete-multiselect` components in prompts package.
92 changes: 92 additions & 0 deletions examples/basic/autocomplete-multiselect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as p from '@clack/prompts';
import color from 'picocolors';

/**
* Example demonstrating the integrated autocomplete multiselect component
* Which combines filtering and selection in a single interface
*/

async function main() {
console.clear();

p.intro(`${color.bgCyan(color.black(' Integrated Autocomplete Multiselect Example '))}`);

p.note(`
${color.cyan('Filter and select multiple items in a single interface:')}
- ${color.yellow('Type')} to filter the list in real-time
- Use ${color.yellow('up/down arrows')} to navigate with improved stability
- Press ${color.yellow('Space')} to select/deselect the highlighted item ${color.green('(multiple selections allowed)')}
- Use ${color.yellow('Backspace')} to modify your filter text when searching for different options
- Press ${color.yellow('Enter')} when done selecting all items
- Press ${color.yellow('Ctrl+C')} to cancel
`, 'Instructions');

// Frameworks in alphabetical order
const frameworks = [
{ value: 'angular', label: 'Angular', hint: 'Frontend/UI' },
{ value: 'django', label: 'Django', hint: 'Python Backend' },
{ value: 'dotnet', label: '.NET Core', hint: 'C# Backend' },
{ value: 'electron', label: 'Electron', hint: 'Desktop' },
{ value: 'express', label: 'Express', hint: 'Node.js Backend' },
{ value: 'flask', label: 'Flask', hint: 'Python Backend' },
{ value: 'flutter', label: 'Flutter', hint: 'Mobile' },
{ value: 'laravel', label: 'Laravel', hint: 'PHP Backend' },
{ value: 'nestjs', label: 'NestJS', hint: 'Node.js Backend' },
{ value: 'nextjs', label: 'Next.js', hint: 'React Framework' },
{ value: 'nuxt', label: 'Nuxt.js', hint: 'Vue Framework' },
{ value: 'rails', label: 'Ruby on Rails', hint: 'Ruby Backend' },
{ value: 'react', label: 'React', hint: 'Frontend/UI' },
{ value: 'reactnative', label: 'React Native', hint: 'Mobile' },
{ value: 'spring', label: 'Spring Boot', hint: 'Java Backend' },
{ value: 'svelte', label: 'Svelte', hint: 'Frontend/UI' },
{ value: 'tauri', label: 'Tauri', hint: 'Desktop' },
{ value: 'vue', label: 'Vue.js', hint: 'Frontend/UI' },
];

// Use the new integrated autocompleteMultiselect component
const result = await p.autocompleteMultiselect<string>({
message: 'Select frameworks (type to filter)',
options: frameworks,
placeholder: 'Type to filter...',
maxItems: 8
});

if (p.isCancel(result)) {
p.cancel('Operation cancelled.');
process.exit(0);
}

// Type guard: if not a cancel symbol, result must be a string array
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === 'string');
}

// We can now use the type guard to ensure type safety
if (!isStringArray(result)) {
throw new Error('Unexpected result type');
}

const selectedFrameworks = result;

// If no items selected, show a message
if (selectedFrameworks.length === 0) {
p.note('No frameworks were selected', 'Empty Selection');
process.exit(0);
}

// Display selected frameworks with detailed information
p.note(`You selected ${color.green(selectedFrameworks.length)} frameworks:`, 'Selection Complete');

// Show each selected framework with its details
const selectedDetails = selectedFrameworks
.map(value => {
const framework = frameworks.find(f => f.value === value);
return framework ? `${color.cyan(framework.label)} ${color.dim(`- ${framework.hint}`)}` : value;
})
.join('\n');

p.log.message(selectedDetails);
p.outro(`Successfully selected ${color.green(selectedFrameworks.length)} frameworks.`);
}

main().catch(console.error);
56 changes: 56 additions & 0 deletions examples/basic/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as p from '@clack/prompts';
import color from 'picocolors';

async function main() {
console.clear();

p.intro(`${color.bgCyan(color.black(' Autocomplete Example '))}`);

p.note(`
${color.cyan('This example demonstrates the type-ahead autocomplete feature:')}
- ${color.yellow('Type')} to filter the list in real-time
- Use ${color.yellow('up/down arrows')} to navigate the filtered results
- Press ${color.yellow('Enter')} to select the highlighted option
- Press ${color.yellow('Ctrl+C')} to cancel
`, 'Instructions');

const countries = [
{ value: 'us', label: 'United States', hint: 'NA' },
{ value: 'ca', label: 'Canada', hint: 'NA' },
{ value: 'mx', label: 'Mexico', hint: 'NA' },
{ value: 'br', label: 'Brazil', hint: 'SA' },
{ value: 'ar', label: 'Argentina', hint: 'SA' },
{ value: 'uk', label: 'United Kingdom', hint: 'EU' },
{ value: 'fr', label: 'France', hint: 'EU' },
{ value: 'de', label: 'Germany', hint: 'EU' },
{ value: 'it', label: 'Italy', hint: 'EU' },
{ value: 'es', label: 'Spain', hint: 'EU' },
{ value: 'pt', label: 'Portugal', hint: 'EU' },
{ value: 'ru', label: 'Russia', hint: 'EU/AS' },
{ value: 'cn', label: 'China', hint: 'AS' },
{ value: 'jp', label: 'Japan', hint: 'AS' },
{ value: 'in', label: 'India', hint: 'AS' },
{ value: 'kr', label: 'South Korea', hint: 'AS' },
{ value: 'au', label: 'Australia', hint: 'OC' },
{ value: 'nz', label: 'New Zealand', hint: 'OC' },
{ value: 'za', label: 'South Africa', hint: 'AF' },
{ value: 'eg', label: 'Egypt', hint: 'AF' },
];

const result = await p.autocomplete({
message: 'Select a country',
options: countries,
placeholder: 'Type to search countries...',
maxItems: 8,
});

if (p.isCancel(result)) {
p.cancel('Operation cancelled.');
process.exit(0);
}

const selected = countries.find(c => c.value === result);
p.outro(`You selected: ${color.cyan(selected?.label)} (${color.yellow(selected?.hint)})`);
}

main().catch(console.error);
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export { default as Prompt } from './prompts/prompt.js';
export { default as SelectPrompt } from './prompts/select.js';
export { default as SelectKeyPrompt } from './prompts/select-key.js';
export { default as TextPrompt } from './prompts/text.js';
export { default as AutocompletePrompt } from './prompts/autocomplete.js';
export { block, isCancel, getColumns } from './utils/index.js';
export { updateSettings, settings } from './utils/settings.js';
134 changes: 134 additions & 0 deletions packages/core/src/prompts/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import Prompt, { type PromptOptions } from './prompt.js';

export interface AutocompleteOptions<T extends { value: any }> extends PromptOptions<AutocompletePrompt<T>> {
options: T[];
initialValue?: T['value'];
maxItems?: number;
filterFn?: (input: string, option: T) => boolean;
}

export default class AutocompletePrompt<T extends { value: any; label?: string }> extends Prompt {
options: T[];
filteredOptions: T[];
cursor = 0;
maxItems: number;
filterFn: (input: string, option: T) => boolean;
isNavigationMode = false; // Track if we're in navigation mode
ignoreNextSpace = false; // Track if we should ignore the next space

private get _value() {
return this.filteredOptions[this.cursor];
}

private filterOptions() {
const input = this.value?.toLowerCase() ?? '';
// Remember the currently selected value before filtering
const previousSelectedValue = this.filteredOptions[this.cursor]?.value;

// Filter options based on the current input
this.filteredOptions = input
? this.options.filter(option => this.filterFn(input, option))
: this.options;

// Reset cursor to 0 by default when filtering changes
this.cursor = 0;

// Try to maintain the previously selected item if it still exists in filtered results
if (previousSelectedValue !== undefined && this.filteredOptions.length > 0) {
const newIndex = this.filteredOptions.findIndex(opt => opt.value === previousSelectedValue);
if (newIndex !== -1) {
// Found the same item in new filtered results, keep it selected
this.cursor = newIndex;
}
}
}

private changeValue() {
if (this.filteredOptions.length > 0) {
// Set the selected option's value
this.selectedValue = this._value.value;
}
}

// Store both the search input and the selected value
public selectedValue: any;

constructor(opts: AutocompleteOptions<T>) {
super(opts, true);

this.options = opts.options;
this.filteredOptions = [...this.options];
this.maxItems = opts.maxItems ?? 10;
this.filterFn = opts.filterFn ?? this.defaultFilterFn;

// Set initial value if provided
if (opts.initialValue !== undefined) {
const initialIndex = this.options.findIndex(({ value }) => value === opts.initialValue);
if (initialIndex !== -1) {
this.cursor = initialIndex;
this.selectedValue = this.options[initialIndex].value;
}
}

// Handle keyboard key presses
this.on('key', (key) => {
// Enter navigation mode with arrow keys
if (key === 'up' || key === 'down') {
this.isNavigationMode = true;
}

// Space key in navigation mode should be ignored for input
if (key === ' ' && this.isNavigationMode) {
this.ignoreNextSpace = true;
return false; // Prevent propagation
}

// Exit navigation mode with non-navigation keys
if (key !== 'up' && key !== 'down' && key !== 'return') {
this.isNavigationMode = false;
}
});

// Handle cursor movement
this.on('cursor', (key) => {
switch (key) {
case 'up':
this.isNavigationMode = true;
this.cursor = this.cursor === 0 ? this.filteredOptions.length - 1 : this.cursor - 1;
break;
case 'down':
this.isNavigationMode = true;
this.cursor = this.cursor === this.filteredOptions.length - 1 ? 0 : this.cursor + 1;
break;
}
this.changeValue();
});

// Update filtered options when input changes
this.on('value', (value) => {
// Check if we need to ignore a space
if (this.ignoreNextSpace && value?.endsWith(' ')) {
// Remove the space and reset the flag
this.value = value.replace(/\s+$/, '');
this.ignoreNextSpace = false;
return;
}

// In navigation mode, strip out any spaces
if (this.isNavigationMode && value?.includes(' ')) {
this.value = value.replace(/\s+/g, '');
return;
}

// Normal filtering when not in navigation mode
this.value = value;
this.filterOptions();
});
}

// Default filtering function
private defaultFilterFn(input: string, option: T): boolean {
const label = option.label ?? String(option.value);
return label.toLowerCase().includes(input.toLowerCase());
}
}
23 changes: 22 additions & 1 deletion packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ export default class Prompt {
}

private onKeypress(char: string, key?: Key) {
// First check for ESC key
// Only relevant for ESC in navigation mode scenarios
let keyHandled = false;
if (char === '\x1b' || key?.name === 'escape') {
// We won't do any special handling for ESC in navigation mode for now
// Just let it propagate to the cancel handler below
keyHandled = false;
// Reset any existing flag
(this as any)._keyHandled = false;
}

if (this.state === 'error') {
this.state = 'active';
}
Expand All @@ -200,8 +211,16 @@ export default class Prompt {
this.emit('value', this.opts.placeholder);
}
}

// Call the key event handler and emit the key event
if (char) {
this.emit('key', char.toLowerCase());
// Check if the key handler set the prevented flag
if ((this as any)._keyHandled) {
keyHandled = true;
// Reset the flag
(this as any)._keyHandled = false;
}
}

if (key?.name === 'return') {
Expand All @@ -223,9 +242,11 @@ export default class Prompt {
}
}

if (isActionKey([char, key?.name, key?.sequence], 'cancel')) {
// Only process as cancel if the key wasn't already handled
if (!keyHandled && isActionKey([char, key?.name, key?.sequence], 'cancel')) {
this.state = 'cancel';
}

if (this.state === 'submit' || this.state === 'cancel') {
this.emit('finalize');
}
Expand Down
Loading