Combobox
An input with a popup listbox for searching and selecting values from a collection.
<ui:listCollection
items="{
0: {value: 'amsterdam', label: 'Amsterdam'},
1: {value: 'athens', label: 'Athens'},
2: {value: 'barcelona', label: 'Barcelona'},
3: {value: 'berlin', label: 'Berlin'},
4: {value: 'copenhagen', label: 'Copenhagen'},
5: {value: 'dublin', label: 'Dublin'},
6: {value: 'helsinki', label: 'Helsinki'},
7: {value: 'lisbon', label: 'Lisbon'},
8: {value: 'madrid', label: 'Madrid'},
9: {value: 'oslo', label: 'Oslo'},
10: {value: 'paris', label: 'Paris'},
11: {value: 'prague', label: 'Prague'},
12: {value: 'rome', label: 'Rome'},
13: {value: 'stockholm', label: 'Stockholm'},
14: {value: 'vienna', label: 'Vienna'},
15: {value: 'warsaw', label: 'Warsaw'}
}"
as="collection" />
<ui:combobox.root collection="{collection}">
<ui:combobox.label>Select a city</ui:combobox.label>
<ui:combobox.control class="min-w-64">
<ui:combobox.input placeholder="Search for cities" />
<ui:combobox.clearTrigger />
<ui:combobox.trigger>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 opacity-70">
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</ui:combobox.trigger>
</ui:combobox.control>
<ui:combobox.content>
<f:for each="{collection.items}" as="item">
<ui:combobox.item item="{item}">
<ui:combobox.itemText>{item.label}</ui:combobox.itemText>
<ui:combobox.itemIndicator />
</ui:combobox.item>
</f:for>
</ui:combobox.content>
</ui:combobox.root>
import { mount } from 'fluid-primitives';
import { Combobox } from 'fluid-primitives/combobox';
mount('combobox', ({ props }) => {
const combobox = new Combobox(props);
combobox.init();
return combobox;
});
Features
- Searchable listbox with keyboard navigation
- Supports single and multiple selection
- Supports disabled items and item groups
- Works with the Field component for forms and validation
- Supports custom client-side filtering via
setFilter() - Uses locale-aware fallback filtering based on Zag's i18n utilities
- Supports async, server-rendered search results via
ui:template
Installation
typo3 ui:add combobox
Please copy the files manually from GitHub into your project.
Read more about installing Components and Primitives.
Examples
Default Value
Set an initial selected value and render its label into the input on first paint.
<ui:listCollection
items="{
0: {value: 'ng', label: 'Nigeria'},
1: {value: 'gh', label: 'Ghana'},
2: {value: 'ke', label: 'Kenya'},
3: {value: 'ma', label: 'Morocco'},
4: {value: 'za', label: 'South Africa'},
5: {value: 'tz', label: 'Tanzania'},
6: {value: 'ug', label: 'Uganda'},
7: {value: 'zm', label: 'Zambia'},
8: {value: 'zw', label: 'Zimbabwe'},
9: {value: 'sn', label: 'Senegal'},
10: {value: 'rw', label: 'Rwanda'},
11: {value: 'ci', label: 'Cote d\'Ivoire'}
}"
as="collection" />
<ui:combobox.root collection="{collection}" defaultValue="ng">
<ui:combobox.label>Country</ui:combobox.label>
<ui:combobox.control class="min-w-64">
<ui:combobox.input placeholder="Search African countries" />
<ui:combobox.clearTrigger />
<ui:combobox.trigger>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 opacity-70">
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</ui:combobox.trigger>
</ui:combobox.control>
<ui:combobox.content>
<f:for each="{collection.items}" as="item">
<ui:combobox.item item="{item}">
<ui:combobox.itemText>{item.label}</ui:combobox.itemText>
<ui:combobox.itemIndicator />
</ui:combobox.item>
</f:for>
</ui:combobox.content>
</ui:combobox.root>
Disabled Items
Mark specific options as unavailable.
<ui:listCollection
items="{
0: {value: 'postgresql', label: 'PostgreSQL'},
1: {value: 'mysql', label: 'MySQL'},
2: {value: 'sqlite', label: 'SQLite'},
3: {value: 'oracle', label: 'Oracle Database', disabled: true},
4: {value: 'sqlserver', label: 'Microsoft SQL Server', disabled: true},
5: {value: 'mariadb', label: 'MariaDB'},
6: {value: 'cockroach', label: 'CockroachDB'},
7: {value: 'planetscale', label: 'PlanetScale'}
}"
as="collection" />
<ui:combobox.root collection="{collection}">
<ui:combobox.label>Select a database</ui:combobox.label>
<ui:combobox.control class="min-w-64">
<ui:combobox.input placeholder="Search databases" />
<ui:combobox.clearTrigger />
<ui:combobox.trigger>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 opacity-70">
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</ui:combobox.trigger>
</ui:combobox.control>
<ui:combobox.content>
<f:for each="{collection.items}" as="item">
<ui:combobox.item item="{item}">
<ui:combobox.itemText>{item.label}</ui:combobox.itemText>
<ui:combobox.itemIndicator />
</ui:combobox.item>
</f:for>
</ui:combobox.content>
</ui:combobox.root>
With Item Groups
Organize items into labeled groups.
<ui:listCollection
items="{
0: {value: 'typescript', label: 'TypeScript', category: 'Languages'},
1: {value: 'php', label: 'PHP', category: 'Languages'},
2: {value: 'rust', label: 'Rust', category: 'Languages'},
3: {value: 'go', label: 'Go', category: 'Languages'},
4: {value: 'laravel', label: 'Laravel', category: 'Frameworks'},
5: {value: 'nextjs', label: 'Next.js', category: 'Frameworks'},
6: {value: 'symfony', label: 'Symfony', category: 'Frameworks'},
7: {value: 'astro', label: 'Astro', category: 'Frameworks'},
8: {value: 'postgresql', label: 'PostgreSQL', category: 'Databases'},
9: {value: 'sqlite', label: 'SQLite', category: 'Databases'},
10: {value: 'redis', label: 'Redis', category: 'Databases'},
11: {value: 'mariadb', label: 'MariaDB', category: 'Databases'}
}"
groupByKey="category"
groupSort="asc"
as="collection" />
<ui:combobox.root collection="{collection}">
<ui:combobox.label>Search technologies</ui:combobox.label>
<ui:combobox.control class="min-w-64">
<ui:combobox.input placeholder="Search across groups" />
<ui:combobox.clearTrigger />
<ui:combobox.trigger>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 opacity-70">
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</ui:combobox.trigger>
</ui:combobox.control>
<ui:combobox.content>
<f:for each="{collection.group}" as="groupItems" key="groupName">
<ui:combobox.itemGroup>
<ui:combobox.itemGroupLabel class="text-muted-foreground px-2 py-1 text-xs font-medium">{groupName}</ui:combobox.itemGroupLabel>
<f:for each="{groupItems}" as="item">
<ui:combobox.item item="{item}">
<ui:combobox.itemText>{item.label}</ui:combobox.itemText>
<ui:combobox.itemIndicator />
</ui:combobox.item>
</f:for>
</ui:combobox.itemGroup>
</f:for>
</ui:combobox.content>
</ui:combobox.root>
With Form Field
Use the combobox inside Field to share label, name, required and invalid state.
<ui:listCollection
items="{
0: {value: 'us', label: 'United States'},
1: {value: 'uk', label: 'United Kingdom'},
2: {value: 'de', label: 'Germany'},
3: {value: 'fr', label: 'France'},
4: {value: 'nl', label: 'Netherlands'},
5: {value: 'se', label: 'Sweden'},
6: {value: 'no', label: 'Norway'},
7: {value: 'dk', label: 'Denmark'},
8: {value: 'fi', label: 'Finland'},
9: {value: 'it', label: 'Italy'},
10: {value: 'es', label: 'Spain'},
11: {value: 'pt', label: 'Portugal'}
}"
as="collection" />
<ui:field.root name="country" required="{true}">
<ui:combobox.root collection="{collection}">
<ui:combobox.label>Country</ui:combobox.label>
<ui:field.control asChild="{true}">
<ui:combobox.control class="min-w-64">
<ui:combobox.input placeholder="Search your country" />
<ui:combobox.clearTrigger />
<ui:combobox.trigger>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 opacity-70">
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</ui:combobox.trigger>
</ui:combobox.control>
</ui:field.control>
<ui:combobox.content>
<f:for each="{collection.items}" as="item">
<ui:combobox.item item="{item}">
<ui:combobox.itemText>{item.label}</ui:combobox.itemText>
<ui:combobox.itemIndicator />
</ui:combobox.item>
</f:for>
</ui:combobox.content>
</ui:combobox.root>
<ui:field.description>This description comes from the field primitive.</ui:field.description>
<ui:field.error />
</ui:field.root>
Custom Filter API
Use setFilter() in a custom mount entry when you want to override filtering imperatively per instance.
<ui:listCollection
items="{
0: {value: 'amsterdam', label: 'Amsterdam'},
1: {value: 'athens', label: 'Athens'},
2: {value: 'auckland', label: 'Auckland'},
3: {value: 'barcelona', label: 'Barcelona'},
4: {value: 'berlin', label: 'Berlin'},
5: {value: 'boston', label: 'Boston'},
6: {value: 'brisbane', label: 'Brisbane'},
7: {value: 'budapest', label: 'Budapest'},
8: {value: 'chicago', label: 'Chicago'},
9: {value: 'copenhagen', label: 'Copenhagen'}
}"
as="collection" />
<ui:combobox.root collection="{collection}" controlled="{true}" rootId="custom-filter-api">
<ui:combobox.label>Instance `setFilter()`</ui:combobox.label>
<ui:combobox.control class="min-w-64">
<ui:combobox.input placeholder="Type something" />
<ui:combobox.clearTrigger />
<ui:combobox.trigger>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 opacity-70">
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
</svg>
</ui:combobox.trigger>
</ui:combobox.control>
<div class="text-muted-foreground text-xs">Uses a custom mount entry that calls `combobox.setFilter(...)`.</div>
<ui:combobox.content>
<f:for each="{collection.items}" as="item">
<ui:combobox.item item="{item}">
<ui:combobox.itemText>{item.label}</ui:combobox.itemText>
<ui:combobox.itemIndicator />
</ui:combobox.item>
</f:for>
</ui:combobox.content>
</ui:combobox.root>
<vite:asset entry="EXT:docs/Resources/Private/Components/ui/Combobox/Examples/CustomFilterApi.entry.ts" />
Async Search
Load items from a server-side search endpoint as the user types, instead of rendering the full collection up front.
Author the item's markup once inside a ui:template block - it makes ui:ref work on plain, hand-authored elements even though they're technically slot content, not a component's own template body. combobox.item, combobox.itemText, and combobox.itemIndicator all detect they're inside a ui:template block automatically - so we dont need to pass a value prop. On the client, clone the template per search result, populate its ui:ref'd elements directly, and rebuild the collection. Fetching, debouncing, and race-condition handling are left to your own code, typically built on @zag-js/async-list.
collection can be omitted entirely for a combobox with no server-known items at all - it's optional and defaults to empty regardless of searchUrl.
The example below sends the search query via extbase.post() rather than a GET param, sidestepping a cHash mismatch f:uri.action's URL would otherwise hit, and drives its loading/error/empty status placeholder off a single DelayedIndicator so the spinner and status text can never disagree.
<ui:combobox.root
rootId="async-search"
controlled="{true}"
searchUrl="{f:uri.action(action: 'search', controller: 'CitySearch')}">
<ui:combobox.label>Search for a city</ui:combobox.label>
<ui:combobox.control class="min-w-64">
<ui:combobox.input placeholder="Type to search…" />
<ui:combobox.clearTrigger />
</ui:combobox.control>
<div class="text-muted-foreground text-sm">Items are fetched from a search endpoint.</div>
<ui:combobox.content>
<div class="text-muted-foreground flex items-center justify-center gap-2 px-2 py-1.5 text-center text-sm" data-status>
<svg data-status-spinner hidden xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
<span data-status-text>Start typing to search…</span>
</div>
<ui:template name="item-template" component="combobox">
<ui:combobox.item>
<ui:combobox.itemText>
<span {ui:ref(name: 'title' , withId: false)}></span>
<span class="text-muted-foreground text-xs ms-1" {ui:ref(name: 'description' , withId: false)}></span>
</ui:combobox.itemText>
<ui:combobox.itemIndicator />
</ui:combobox.item>
</ui:template>
</ui:combobox.content>
</ui:combobox.root>
<vite:asset entry="EXT:docs/Resources/Private/Components/ui/Combobox/Examples/AsyncSearch.entry.ts" />
import type { Api as AsyncListApi } from '@zag-js/async-list';
import type { CollectionItem } from '@zag-js/collection';
import { ListCollection } from '@zag-js/collection';
import type { InputValueChangeDetails } from '@zag-js/combobox';
import { debounce } from '@zag-js/utils';
import { AsyncList, DelayedIndicator, extbase, mountControlled, Template } from 'fluid-primitives';
import { Combobox } from 'fluid-primitives/combobox';
interface CityResult extends CollectionItem {
value: string;
title: string;
description?: string;
}
const SEARCH_DEBOUNCE_MS = 300;
type SearchStatus = 'loading' | 'error' | 'empty' | 'idle' | 'results';
const STATUS_TEXT: Record<Exclude<SearchStatus, 'results'>, string> = {
loading: 'Searching…',
error: 'Something went wrong. Please try again.',
empty: 'No results found',
idle: 'Start typing to search…',
};
// The item list is folded into the same value the status placeholder is driven by, rather than
// updated separately and immediately from the raw subscribe callback - otherwise the old items
// would be removed the instant a new search starts (api.loading flips true) while the "Searching…"
// placeholder stays hidden for its own showDelayMs, leaving a visible gap with nothing rendered
// in between. Routing both through one DelayedIndicator means the old items only ever disappear
// at the exact moment something else is ready to take their place - fresh results immediately, or
// the placeholder once it's actually shown.
type SearchState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'empty' }
| { status: 'idle' }
| { status: 'results'; items: CityResult[] };
function getSearchState(api: AsyncListApi<CityResult, unknown>): SearchState {
const hasResults = !api.loading && !api.error && !api.empty;
if (hasResults) return { status: 'results', items: api.items };
if (api.loading) return { status: 'loading' };
if (api.error) return { status: 'error' };
return api.filterText.trim() ? { status: 'empty' } : { status: 'idle' };
}
mountControlled('combobox', 'async-search', ({ props, controlled }) => {
const searchUrl = props.searchUrl as string;
let insertedItems: HTMLElement[] = [];
let combobox: Combobox;
function updateItems(items: CityResult[]) {
const contentEl = combobox.getElement<HTMLElement>('content');
if (!contentEl || !combobox.hydrator) return;
insertedItems.forEach(el => el.remove());
insertedItems = [];
const collection = new ListCollection<CityResult>({
items,
itemToValue: item => item.value,
itemToString: item => item.title,
});
for (const { value, title, description } of collection) {
const instance = new Template(combobox.hydrator, 'item-template', { value });
const titleEl = instance.getElement<HTMLElement>('title');
if (titleEl) titleEl.textContent = title;
const descriptionEl = instance.getElement<HTMLElement>('description');
if (description) {
if (descriptionEl) descriptionEl.textContent = description;
} else {
descriptionEl?.remove();
}
contentEl.appendChild(instance);
insertedItems.push(instance.root);
}
combobox.updateProps({ collection });
}
// Renders both the item list and the status placeholder from one incoming state, so the two
// are always in sync - old items and the old status text/spinner only ever change together,
// at the moment DelayedIndicator decides something new is actually ready to be shown.
const searchState = new DelayedIndicator<SearchState>({
isTransient: s => s.status === 'loading',
onChange: state => {
const contentEl = combobox.getElement<HTMLElement>('content');
const statusEl = contentEl?.querySelector<HTMLElement>('[data-status]');
const spinnerEl = contentEl?.querySelector<HTMLElement>('[data-status-spinner]');
const textEl = contentEl?.querySelector<HTMLElement>('[data-status-text]');
if (!statusEl || !spinnerEl || !textEl) return;
const hasResults = state.status === 'results';
updateItems(hasResults ? state.items : []);
statusEl.toggleAttribute('hidden', hasResults);
if (hasResults) return;
spinnerEl.toggleAttribute('hidden', state.status !== 'loading');
textEl.textContent = STATUS_TEXT[state.status];
},
});
const list = new AsyncList<CityResult>({
load: async ({ signal, filterText }) => {
if (!filterText.trim()) return { items: [] as CityResult[] };
// post() namespaces { q: filterText } under searchUrl's own tx_docs_docs[...]
// prefix automatically and sends it as a POST body, sidestepping the cHash mismatch
// a GET query param appended after the fact would otherwise cause (f:uri.action's
// cHash is computed from the arguments known at build time).
const response = await extbase.post(searchUrl, { q: filterText }, { signal });
if (!response.ok) {
throw new Error(`City search failed with status ${response.status}`);
}
return { items: (await response.json()) as CityResult[] };
},
});
list.subscribe(api => {
searchState.set(getSearchState(api));
});
// Debounced here rather than inside AsyncList itself - a plain wrap of setFilterText, the
// same way you'd debounce any other callback.
const setFilterTextDebounced = debounce(
(filterText: string) => list.setFilterText(filterText),
SEARCH_DEBOUNCE_MS
);
combobox = new Combobox({
...props,
controlled,
onInputValueChange: (details: InputValueChangeDetails) => {
if (details.reason === 'input-change') setFilterTextDebounced(details.inputValue);
},
});
list.init();
combobox.init();
return combobox;
});
Async Search with Groups
Async results can be grouped too - author a second ui:template for the group wrapper (combobox.itemGroup/combobox.itemGroupLabel), clone one per group returned by your search, and append the item clones into it instead of directly into combobox.content.
combobox.itemGroup needs a unique data-id per instance so Combobox can tell groups apart - the same thing ui:id() gives a server-rendered group, done client-side with uid(). Nothing about Combobox's own rendering needed to change for this: it already looks up every [data-part="item-group"] element independently and reads its data-id fresh on every render, whether that element was server-rendered or just cloned.
<ui:combobox.root
rootId="async-search-grouped"
controlled="{true}"
searchUrl="{f:uri.action(action: 'search', controller: 'CitySearch')}">
<ui:combobox.label>Search for a city</ui:combobox.label>
<ui:combobox.control class="min-w-64">
<ui:combobox.input placeholder="Type to search…" />
<ui:combobox.clearTrigger />
</ui:combobox.control>
<div class="text-muted-foreground text-sm">Items are fetched from a search endpoint.</div>
<ui:combobox.content>
<div class="text-muted-foreground flex items-center justify-center gap-2 px-2 py-1.5 text-center text-sm" data-status>
<svg data-status-spinner hidden xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
<span data-status-text>Start typing to search…</span>
</div>
<ui:template name="group-template" component="combobox">
<ui:combobox.itemGroup>
<ui:combobox.itemGroupLabel class="text-muted-foreground px-2 py-1 text-xs font-medium">
<span {ui:ref(name: 'group-label' , withId: false)}></span>
</ui:combobox.itemGroupLabel>
</ui:combobox.itemGroup>
</ui:template>
<ui:template name="item-template" component="combobox">
<ui:combobox.item>
<ui:combobox.itemText>
<span {ui:ref(name: 'title' , withId: false)}></span>
</ui:combobox.itemText>
<ui:combobox.itemIndicator />
</ui:combobox.item>
</ui:template>
</ui:combobox.content>
</ui:combobox.root>
<vite:asset entry="EXT:docs/Resources/Private/Components/ui/Combobox/Examples/AsyncSearchGrouped.entry.ts" />
import type { Api as AsyncListApi } from '@zag-js/async-list';
import type { CollectionItem } from '@zag-js/collection';
import { ListCollection } from '@zag-js/collection';
import type { InputValueChangeDetails } from '@zag-js/combobox';
import { debounce } from '@zag-js/utils';
import {
AsyncList,
DelayedIndicator,
extbase,
mountControlled,
Template,
uid,
} from 'fluid-primitives';
import { Combobox } from 'fluid-primitives/combobox';
interface CityResult extends CollectionItem {
value: string;
title: string;
description?: string;
}
const SEARCH_DEBOUNCE_MS = 300;
type SearchStatus = 'loading' | 'error' | 'empty' | 'idle' | 'results';
const STATUS_TEXT: Record<Exclude<SearchStatus, 'results'>, string> = {
loading: 'Searching…',
error: 'Something went wrong. Please try again.',
empty: 'No results found',
idle: 'Start typing to search…',
};
// The group/item list is folded into the same value the status placeholder is driven by, rather
// than updated separately and immediately from the raw subscribe callback - otherwise the old
// groups would be removed the instant a new search starts (api.loading flips true) while the
// "Searching…" placeholder stays hidden for its own showDelayMs, leaving a visible gap with
// nothing rendered in between. Routing both through one DelayedIndicator means the old groups
// only ever disappear at the exact moment something else is ready to take their place - fresh
// results immediately, or the placeholder once it's actually shown.
type SearchState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'empty' }
| { status: 'idle' }
| { status: 'results'; items: CityResult[] };
function getSearchState(api: AsyncListApi<CityResult, unknown>): SearchState {
const hasResults = !api.loading && !api.error && !api.empty;
if (hasResults) return { status: 'results', items: api.items };
if (api.loading) return { status: 'loading' };
if (api.error) return { status: 'error' };
return api.filterText.trim() ? { status: 'empty' } : { status: 'idle' };
}
mountControlled('combobox', 'async-search-grouped', ({ props, controlled }) => {
const searchUrl = props.searchUrl as string;
let insertedGroups: HTMLElement[] = [];
let combobox: Combobox;
function updateItems(items: CityResult[]) {
const contentEl = combobox.getElement<HTMLElement>('content');
if (!contentEl || !combobox.hydrator) return;
insertedGroups.forEach(el => el.remove());
insertedGroups = [];
// Grouping by country is baked into the collection itself (groupBy/groupSort), the same
// way the static "With Item Groups" example groups server-side via groupByKey/groupSort -
// group() then just reads back what the collection already grouped/sorted.
const collection = new ListCollection<CityResult>({
items,
itemToValue: item => item.value,
itemToString: item => item.title,
groupBy: item => item.description || 'Other',
groupSort: 'asc',
});
for (const [country, countryItems] of collection.group()) {
const group = new Template(combobox.hydrator, 'group-template');
// Only the value/identity discriminator Combobox's own render() needs to tell groups
// apart - the label text and item rendering are unaffected by its exact value.
group.root.dataset.id = uid();
const labelEl = group.getElement<HTMLElement>('group-label');
if (labelEl) labelEl.textContent = country;
for (const { value, title } of countryItems) {
const item = new Template(combobox.hydrator, 'item-template', { value });
const titleEl = item.getElement<HTMLElement>('title');
if (titleEl) titleEl.textContent = title;
group.root.appendChild(item);
}
contentEl.appendChild(group);
insertedGroups.push(group.root);
}
combobox.updateProps({ collection });
}
// Renders both the group/item list and the status placeholder from one incoming state, so
// the two are always in sync - old groups and the old status text/spinner only ever change
// together, at the moment DelayedIndicator decides something new is actually ready to be shown.
const searchState = new DelayedIndicator<SearchState>({
isTransient: s => s.status === 'loading',
onChange: state => {
const contentEl = combobox.getElement<HTMLElement>('content');
const statusEl = contentEl?.querySelector<HTMLElement>('[data-status]');
const spinnerEl = contentEl?.querySelector<HTMLElement>('[data-status-spinner]');
const textEl = contentEl?.querySelector<HTMLElement>('[data-status-text]');
if (!statusEl || !spinnerEl || !textEl) return;
const hasResults = state.status === 'results';
updateItems(hasResults ? state.items : []);
statusEl.toggleAttribute('hidden', hasResults);
if (hasResults) return;
spinnerEl.toggleAttribute('hidden', state.status !== 'loading');
textEl.textContent = STATUS_TEXT[state.status];
},
});
const list = new AsyncList<CityResult>({
load: async ({ signal, filterText }) => {
if (!filterText.trim()) return { items: [] as CityResult[] };
// post() namespaces { q: filterText } under searchUrl's own tx_docs_docs[...]
// prefix automatically and sends it as a POST body, sidestepping the cHash mismatch
// a GET query param appended after the fact would otherwise cause (f:uri.action's
// cHash is computed from the arguments known at build time).
const response = await extbase.post(searchUrl, { q: filterText }, { signal });
if (!response.ok) {
throw new Error(`City search failed with status ${response.status}`);
}
return { items: (await response.json()) as CityResult[] };
},
});
list.subscribe(api => {
searchState.set(getSearchState(api));
});
// Debounced here rather than inside AsyncList itself - a plain wrap of setFilterText, the
// same way you'd debounce any other callback.
const setFilterTextDebounced = debounce(
(filterText: string) => list.setFilterText(filterText),
SEARCH_DEBOUNCE_MS
);
combobox = new Combobox({
...props,
controlled,
onInputValueChange: (details: InputValueChangeDetails) => {
if (details.reason === 'input-change') setFilterTextDebounced(details.inputValue);
},
});
list.init();
combobox.init();
return combobox;
});
Localization
Default combobox trigger labels are shipped via XLF and follow the current Site Language. For per-template overrides, pass translated strings through the translations prop. Set a translation entry to {false} or an empty string to omit the corresponding aria-label.
Note that Zag.js uses a function for the trigger label to allow dynamic labels based on the copied state. Fluid Primitives simplifies this by accepting static strings for both states, which are then merged into the appropriate function internally.
<f:variable
name="comboboxTranslations"
value="{
triggerLabel: '{f:translate(key: \'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:combobox.trigger\')}',
clearTriggerLabel: '{f:translate(key: \'LLL:EXT:site_package/Resources/Private/Language/locallang.xlf:combobox.clear\')}'
}"
/>
<ui:combobox.root translations="{comboboxTranslations}"> ... </ui:combobox.root>
Anatomy
<primitives:combobox.root>
<primitives:combobox.label />
<primitives:combobox.control>
<primitives:combobox.input />
<primitives:combobox.clearTrigger />
<primitives:combobox.trigger />
</primitives:combobox.control>
<primitives:combobox.positioner>
<primitives:combobox.content>
<primitives:combobox.item>
<primitives:combobox.itemText />
<primitives:combobox.itemIndicator />
</primitives:combobox.item>
<primitives:combobox.itemGroup>
<primitives:combobox.itemGroupLabel />
</primitives:combobox.itemGroup>
</primitives:combobox.content>
</primitives:combobox.positioner>
</primitives:combobox.root>