Dynamic Menus
This guide shows practical patterns for driving pok dynamic menus from a data source. The examples use piq, a query engine for markdown collections, but the provider contract is the same for any async source — a database, an HTTP API, or the filesystem.
The provider contract
A dynamic select takes a provider: a single async function that receives the
current type-ahead filter and an AbortSignal, and resolves to the full
array of options to display. The UI adapter owns loading, debounce, and
filtering presentation — those are not part of the contract.
type OptionsProvider<T> = (
filter: string | undefined,
signal: AbortSignal
) => Promise<SelectOption<T>[]>;
type SelectOption<T> = {
value: T;
label: string;
hint?: string;
group?: string; // optional visual grouping
};Prerequisites
- pok dynamic menus (
provideronprompter.select()) - a piq collection/resolver configured in your
app (for the examples below; piq queries select fields via the
params.*,frontmatter.*, andbody.*namespaces)
Basic: Collection to Menu
import { piq } from 'piqit';
import type { OptionsProvider } from '@pokit/core';
const postsProvider: OptionsProvider<string> = async (filter, signal) => {
const results = await piq
.from(posts)
.scan({})
.select('params.slug', 'frontmatter.title', 'params.year')
.exec();
return results.map((r) => ({
value: r.slug,
label: r.title ?? r.slug,
hint: r.year,
}));
};
const selected = await prompter.select({
message: 'Select a post to edit',
provider: postsProvider,
});Typeahead: Server-Side Filtering
The provider is re-invoked with the current filter. Use piq's scan() for param
constraints and filter() for frontmatter constraints, and forward the signal
so in-flight queries are cancelled.
import { piq } from 'piqit';
import type { OptionsProvider } from '@pokit/core';
const filterableProvider: OptionsProvider<string> = async (filter, signal) => {
const query = piq.from(posts).scan({});
if (filter) {
query.scan({ tag: filter });
}
const results = await query
.select('params.slug', 'params.tag', 'frontmatter.title')
.exec();
return results.map((r) => ({
value: r.slug,
label: r.title ?? r.slug,
hint: r.tag,
}));
};
const selected = await prompter.select({
message: 'Search posts by tag',
provider: filterableProvider,
});Frontmatter-driven filter example:
type PostStatus = 'draft' | 'published' | 'archived';
const statusFilterProvider: OptionsProvider<string> = async (filter) => {
const query = piq.from(posts).scan({});
if (filter && ['draft', 'published', 'archived'].includes(filter)) {
query.filter({ status: filter as PostStatus });
}
const results = await query
.select('params.slug', 'frontmatter.title', 'frontmatter.status')
.exec();
return results.map((r) => ({
value: r.slug,
label: r.title ?? r.slug,
hint: r.status,
}));
};Grouping
Use the group field to visually cluster options (like an <optgroup>):
const groupedProvider: OptionsProvider<string> = async () => {
const rows = await piq
.from(posts)
.scan({})
.select('params.slug', 'frontmatter.title', 'params.year')
.exec();
return rows.map((r) => ({
value: r.slug,
label: r.title ?? r.slug,
group: r.year, // options with the same group render together
}));
};Layered Resolution Pattern
Load only what the menu needs. Pull heavier fields later, after selection.
const selected = await prompter.select({
message: 'Select a post',
provider: async () => {
const rows = await piq
.from(posts)
.scan({ year: '2026' })
.select('params.slug', 'frontmatter.title')
.exec();
return rows.map((r) => ({ value: r.slug, label: r.title ?? r.slug }));
},
});
// Follow-up query: heavier body fields only for the selected row
const post = await piq
.from(posts)
.scan({ slug: selected })
.select('params.slug', 'frontmatter.title', 'body.html', 'body.headings')
.single()
.exec();Hierarchical Menus from Path Params
const categoryProvider: OptionsProvider<string> = async () => {
const rows = await piq.from(docs).scan({}).select('params.category').exec();
const categories = [...new Set(rows.map((r) => r.category))];
return categories.map((category) => ({
value: category,
label: category.replace(/-/g, ' '),
}));
};
const docsInCategoryProvider =
(category: string): OptionsProvider<string> =>
async () => {
const rows = await piq
.from(docs)
.scan({ category })
.select('params.slug', 'frontmatter.title')
.exec();
return rows.map((r) => ({ value: r.slug, label: r.title ?? r.slug }));
};Error Handling
A provider that throws surfaces the configured errorMessage. Return a sentinel
option for the empty case:
const resilientProvider: OptionsProvider<string> = async () => {
const rows = await piq
.from(posts)
.scan({})
.select('params.slug', 'frontmatter.title')
.exec();
if (rows.length === 0) {
return [{ value: '__empty__', label: 'No posts found', hint: 'Create a post first' }];
}
return rows.map((r) => ({ value: r.slug, label: r.title ?? r.slug }));
};Pagination
The prompter provider resolves the full option set in one call; the UI adapter
handles scrolling. If a data source is genuinely paginated, page through it
inside the provider before returning, or use command-level resolve() on a
context field, which additionally accepts a single page ({ options, nextCursor })
or an async iterator of pages. See defineCommand.