-
Notifications
You must be signed in to change notification settings - Fork 147
feat(core + prompts): adds autocomplete #288
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
5b2f38a
feat: adds autocomplete
dreyfus92 0f4aa04
Apply suggestions from @MacFJA and @natemoo.re reviews
dreyfus92 e207c32
Merge branch 'main' into feat/autocomplete
dreyfus92 6ae902c
add: missing changes after merge
dreyfus92 d7523e3
chore: use selectedValue getter
43081j 74ffae6
chore: run format
43081j 9b1ebc5
core rework/simplification (#308)
43081j e3ef952
Apply suggestions from @43081j & @MacFJA's review
dreyfus92 4eebe3c
Apply suggestions from @43081j's review
dreyfus92 3a16bcb
tests: refactor autocomplete prompt tests to avoid core functionality…
dreyfus92 6f11483
format
dreyfus92 a65db40
fix: handle undefined values in text prompts
43081j 0c034ff
Apply suggestions from @43081j's review
dreyfus92 2dfbdf8
Apply suggestions from @43081j's review
dreyfus92 984a92d
remove: overflowFormat param from LimitOptionsParams interface
dreyfus92 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
43081j marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
} | ||
|
||
// 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+$/, ''); | ||
43081j marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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; | ||
dreyfus92 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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()); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.