Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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/every-aliens-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clack/prompts": minor
"@clack/core": minor
---

Adds support for loading autocomplete results from an async function
103 changes: 103 additions & 0 deletions examples/basic/autocomplete-async-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as p from '@clack/prompts';
import color from 'picocolors';

/**
* Example demonstrating async autocomplete with API search
* Uses the REST Countries API to search for countries by name
*/

interface Country {
code: string;
name: string;
region: string;
}

async function searchCountries(query: string, signal?: AbortSignal): Promise<Country[]> {
if (!query) {
// Return empty array for empty query
return [];
}

try {
const response = await fetch(
`https://restcountries.com/v3.1/name/${encodeURIComponent(query)}`,
{
signal,
}
);

if (!response.ok) {
if (response.status === 404) {
// No countries found
return [];
}
throw new Error(`API error: ${response.status}`);
}

const countries: Array<{ cca2: string; name: { common: string }; region: string }> =
await response.json();

return countries.map((country) => ({
code: country.cca2,
name: country.name.common,
region: country.region,
}));
} catch (error) {
// If request was aborted, return empty array
if (signal?.aborted) {
return [];
}
throw error;
}
}

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

p.intro(`${color.bgCyan(color.black(' Async Autocomplete - API Search '))}`);

p.note(
`
${color.cyan('This example demonstrates async autocomplete with API search:')}
- Type to search countries via ${color.yellow('REST Countries API')}
- Search is ${color.yellow('debounced')} to avoid excessive requests
- Previous requests are ${color.yellow('cancelled')} when you type more
- Shows ${color.yellow('loading spinner')} during API calls
- Handles ${color.yellow('errors gracefully')}
`,
'Instructions'
);

const result = await p.autocomplete<Country>({
message: 'Search for a country',
filteredOptions: async (query, signal) => {
const countries = await searchCountries(query, signal);
return countries.map((country) => ({
value: country,
label: country.name,
hint: country.region,
}));
},
debounce: 300, // Wait 300ms after user stops typing
});

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

// Display result
const country = result as Country;
p.note(
`
${color.green('Name:')} ${country.name}
${color.green('Code:')} ${country.code}
${color.green('Region:')} ${country.region}
`,
'Selected Country'
);

p.outro('Done!');
}

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

/**
* Example demonstrating the autocomplete multiselect with required validation
* This reproduces the behavior from the skipped test:
* "renders error when empty selection & required is true"
*/

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

p.intro(`${color.bgCyan(color.black(' Autocomplete Multiselect - Required Validation '))}`);

p.note(
`
${color.cyan('This example demonstrates required validation:')}
- ${color.yellow('Type')} to filter the list
- Use ${color.yellow('up/down arrows')} to navigate
- Press ${color.yellow('Space')} or ${color.yellow('Tab')} to select items
- Press ${color.yellow('Enter')} to submit ${color.red('(must select at least one item)')}
- Try submitting without selecting - you should see an error!
`,
'Instructions'
);

const testOptions = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'grape', label: 'Grape' },
{ value: 'orange', label: 'Orange' },
];

// Use autocompleteMultiselect with required: true
const result = await p.autocompleteMultiselect<string>({
message: 'Select a fruit',
options: testOptions,
required: true, // This should trigger validation if nothing is selected
});

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

// Type guard
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}

if (!isStringArray(result)) {
throw new Error('Unexpected result type');
}

const selectedFruits = result;

// Display result
if (selectedFruits.length === 0) {
p.note(
color.yellow('No fruits were selected (this should not happen with required: true)'),
'Warning'
);
} else {
p.note(`You selected: ${color.green(selectedFruits.join(', '))}`, 'Selection Complete');
}

p.outro('Done!');
}

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

/**
* Example demonstrating sync autocomplete with custom filtering logic
* Uses filteredOptions to implement fuzzy matching
*/

interface Package {
name: string;
description: string;
downloads: number;
}

const packages: Package[] = [
{
name: 'react',
description: 'A JavaScript library for building user interfaces',
downloads: 20000000,
},
{ name: 'vue', description: 'Progressive JavaScript Framework', downloads: 5000000 },
{
name: 'angular',
description: 'Platform for building mobile and desktop apps',
downloads: 3000000,
},
{ name: 'svelte', description: 'Cybernetically enhanced web apps', downloads: 1000000 },
{ name: 'next', description: 'The React Framework for Production', downloads: 8000000 },
{ name: 'nuxt', description: 'The Intuitive Vue Framework', downloads: 2000000 },
{ name: 'express', description: 'Fast, unopinionated web framework', downloads: 15000000 },
{ name: 'fastify', description: 'Fast and low overhead web framework', downloads: 500000 },
{ name: 'vite', description: 'Next generation frontend tooling', downloads: 4000000 },
{ name: 'webpack', description: 'Module bundler', downloads: 12000000 },
];

/**
* Custom fuzzy matching filter
* Matches if all characters in query appear in order in the name
*/
function fuzzyMatch(query: string, text: string): boolean {
if (!query) return true;

const queryChars = query.toLowerCase().split('');
const textLower = text.toLowerCase();
let textIndex = 0;

for (const char of queryChars) {
const foundIndex = textLower.indexOf(char, textIndex);
if (foundIndex === -1) return false;
textIndex = foundIndex + 1;
}

return true;
}

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

p.intro(`${color.bgCyan(color.black(' Sync Autocomplete - Custom Filtering '))}`);

p.note(
`
${color.cyan('This example demonstrates sync autocomplete with custom filtering:')}
- Uses ${color.yellow('fuzzy matching')} algorithm
- Type partial characters: "${color.yellow('rx')}" matches "${color.green('react')}"
- ${color.yellow('Instant results')} - no debouncing needed for sync
- Also searches in ${color.yellow('descriptions')}
`,
'Instructions'
);

const result = await p.autocomplete<Package>({
message: 'Select a package',
filteredOptions: (query) => {
// Custom filtering logic with fuzzy matching
return packages
.filter((pkg) => fuzzyMatch(query, pkg.name) || fuzzyMatch(query, pkg.description))
.sort((a, b) => b.downloads - a.downloads) // Sort by popularity
.map((pkg) => ({
value: pkg,
label: pkg.name,
hint: `${(pkg.downloads / 1000000).toFixed(1)}M downloads`,
}));
},
});

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

// Display result
const pkg = result as Package;
p.note(
`
${color.green('Name:')} ${pkg.name}
${color.green('Description:')} ${pkg.description}
${color.green('Downloads:')} ${(pkg.downloads / 1000000).toFixed(1)}M/month
`,
'Selected Package'
);

p.outro('Done!');
}

main().catch(console.error);
5 changes: 4 additions & 1 deletion examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
"path": "jiti ./path.ts",
"spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts",
"spinner-timer": "jiti ./spinner-timer.ts",
"task-log": "jiti ./task-log.ts"
"task-log": "jiti ./task-log.ts",
"autocomplete-multiselect-required": "jiti ./autocomplete-multiselect-required.ts",
"autocomplete-async-api": "jiti ./autocomplete-async-api.ts",
"autocomplete-sync-custom": "jiti ./autocomplete-sync-custom.ts"
},
"devDependencies": {
"cross-env": "^7.0.3"
Expand Down
Loading