Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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