Autocomplete
An editable combobox for debounced backend search with multi-selection — labels, filter facets, people pickers — where picked values render as removable chips inside the field.
Autocomplete fills the gap SelectMenu deliberately doesn't cover: SelectMenu is a
fixed/synchronous options list behind a non-editable trigger, while Autocomplete is always multi-selection
and always backed by an async loader. Visually the two are the same component family — both wear the same
selectWrapVariants wrap, so label, field, popup, rows and hint come from one design-system source of truth.
It is the one component behind every "type, pick from a list" field of the library, and it replaces SearchSelect.
Concept — never owns persistence
Autocomplete never mutates its own applied values. selected is input-only, and picked/removed only
report the user's intent — the host calls its own backend (with error handling) before reflecting the change
back into selected. This mirrors a real "add labels to a document" flow.
When to use it
Use it when
The options come from an async source and are too many to list (labels, filter facets, people, tags with suggestions), the user builds a multiple selection shown as removable chips, and the host runs its own call before the pick lands.
Don't use it when
The options are a fixed synchronous list (SelectMenu), the value is free text with no
suggestions (TagInput), the field is a search box that filters a page
(SearchInput, SearchBar), or it is a facet with counters or a tree.
It is multi-selection by design — picked appends. A single value out of a search is possible with closeOnPick and
a host that replaces selected with one entry, but if that becomes the common case it deserves its own contract.
Minimal example
import { Component, signal } from "@angular/core";
import { AutocompleteComponent } from "@sinequa/galactik";
interface Fruit {
id: string;
name: string;
}
const ALL_FRUITS: Fruit[] = [
{ id: "apple", name: "Apple" },
{ id: "banana", name: "Banana" },
{ id: "cherry", name: "Cherry" },
];
@Component({
selector: "sample-component",
imports: [AutocompleteComponent],
template: `
<Autocomplete
[selected]="selectedFruits()"
[loader]="search"
[key]="keyOf"
[label]="labelOf"
fieldLabel="Fruits"
placeholder="Search fruits…"
(picked)="onPicked($event)"
(removed)="onRemoved($event)" />
`,
})
export class SampleComponent {
selectedFruits = signal<Fruit[]>([]);
keyOf = (fruit: Fruit) => fruit.id;
labelOf = (fruit: Fruit) => fruit.name;
search = (query: string, { signal }: { signal: AbortSignal }): Promise<Fruit[]> =>
Promise.resolve(ALL_FRUITS.filter((f) => f.name.toLowerCase().includes(query.toLowerCase())));
onPicked(fruit: Fruit) {
this.selectedFruits.update((fruits) => [...fruits, fruit]);
}
onRemoved(fruit: Fruit) {
this.selectedFruits.update((fruits) => fruits.filter((f) => f.id !== fruit.id));
}
}How it works
A search fires once the typed query, debounced, reaches minLength characters. While it is in flight the field's
chevron gives way to a spinner and the rows of the previous search stay on screen — replacing them with a
"Searching…" line made the list blink on every keystroke — so searchingText only shows when there is nothing
to keep. Only a search that has come back (empty) may claim there is nothing, and a failed one says so in an
alert row (errorText). The loader receives an AbortSignal: a newer query aborts the request of the older
one — hand it to fetch. The popup is a manual popover in the top layer, so a dialog's edges don't clip it.
The suggestion under the mouse becomes the active row (activateOnHover), so only one row is highlighted and the arrow
keys resume from it; Enter without any arrow or hover still picks the first one.
Recipes
Persisting the pick before reflecting it
picked/removed only report intent — the host is responsible for the backend call and for updating
selected only on success.
import { Component, signal } from "@angular/core";
import { AutocompleteComponent } from "@sinequa/galactik";
interface Label {
id: string;
name: string;
}
@Component({
selector: "label-picker",
imports: [AutocompleteComponent],
template: `
<Autocomplete
[selected]="appliedLabels()"
[loader]="searchLabels"
[minLength]="2"
[debounceMs]="250"
[key]="keyOf"
[label]="labelOf"
fieldLabel="Labels"
placeholder="Search labels…"
noResultsText="No matching labels"
(picked)="onPicked($event)"
(removed)="onRemoved($event)" />
`,
})
export class LabelPickerComponent {
appliedLabels = signal<Label[]>([]);
keyOf = (label: Label) => label.id;
labelOf = (label: Label) => label.name;
searchLabels = (query: string): Promise<Label[]> => this.fetchLabels(query);
async onPicked(label: Label) {
try {
await this.attachLabel(label.id);
this.appliedLabels.update((labels) => [...labels, label]);
} catch {
// keep appliedLabels untouched — the pick is not reflected on failure
}
}
async onRemoved(label: Label) {
try {
await this.detachLabel(label.id);
this.appliedLabels.update((labels) => labels.filter((l) => l.id !== label.id));
} catch {
// keep appliedLabels untouched — the removal is not reflected on failure
}
}
// Stand-ins for the host application's own backend calls.
private fetchLabels(query: string): Promise<Label[]> {
return Promise.resolve([]);
}
private attachLabel(id: string): Promise<void> {
return Promise.resolve();
}
private detachLabel(id: string): Promise<void> {
return Promise.resolve();
}
}A people picker
When the applied values aren't shaped like the suggestions (a person found in a directory becomes an entry with
a role), selectedKey and selectedLabel tell the component how to read them. excludeKeys keeps out
people who already have access, closeOnPick closes the list and clears the text after a pick — a chat's
mention box — and an element marked autocompleteSuffix is projected inside the field, after the text.
import { Component, signal } from "@angular/core";
import { AutocompleteComponent } from "@sinequa/galactik";
interface Person {
userId: string;
name: string;
email: string;
}
interface Entry {
id: string;
name: string;
role: "reader" | "owner";
}
@Component({
selector: "people-picker",
imports: [AutocompleteComponent],
template: `
<Autocomplete
[selected]="entries()"
[loader]="search"
[key]="personKey"
[label]="personName"
[selectedKey]="entryKey"
[selectedLabel]="entryName"
[excludeKeys]="alreadyHaveAccess"
[indicator]="false"
[closeOnPick]="true"
placeholder="Search people…"
ariaLabel="Search people"
(picked)="pick($event)"
(removed)="remove($event)">
<ng-template #autocompleteOption let-person>
<span class="flex flex-col">
<span>{{ person.name }}</span>
<span class="text-xs">{{ person.email }}</span>
</span>
</ng-template>
<button autocompleteSuffix type="button" (click)="nextRole.set(nextRole() === 'reader' ? 'owner' : 'reader')">
as {{ nextRole() }}
</button>
</Autocomplete>
`,
})
export class PeoplePickerComponent {
entries = signal<Entry[]>([]);
nextRole = signal<"reader" | "owner">("reader");
alreadyHaveAccess = ["domain|grace"];
personKey = (p: Person) => p.userId;
personName = (p: Person) => p.name;
entryKey = (e: Entry) => e.id;
entryName = (e: Entry) => e.name;
search = (query: string, { signal }: { signal: AbortSignal }): Promise<Person[]> =>
fetch(`/api/people?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json());
pick(person: Person) {
this.entries.update((entries) => [...entries, { id: person.userId, name: person.name, role: this.nextRole() }]);
}
remove(entry: Entry) {
this.entries.update((entries) => entries.filter((e) => e.id !== entry.id));
}
}Free-text creation
Setting createFromText lets pressing Enter with no suggestions shown create a new option straight
from the typed text.
import { Component, signal } from "@angular/core";
import { AutocompleteComponent } from "@sinequa/galactik";
interface Tag {
id: string;
name: string;
}
@Component({
selector: "tag-creator",
imports: [AutocompleteComponent],
template: `
<Autocomplete
[selected]="tags()"
[loader]="searchTags"
[createFromText]="createTag"
[key]="keyOf"
[label]="labelOf"
placeholder="Search or create a tag…"
(picked)="onPicked($event)"
(removed)="onRemoved($event)" />
`,
})
export class TagCreatorComponent {
tags = signal<Tag[]>([]);
keyOf = (tag: Tag) => tag.id;
labelOf = (tag: Tag) => tag.name;
searchTags = (query: string): Promise<Tag[]> => Promise.resolve([]);
createTag = (text: string): Tag => ({ id: crypto.randomUUID(), name: text });
onPicked(tag: Tag) {
this.tags.update((tags) => [...tags, tag]);
}
onRemoved(tag: Tag) {
this.tags.update((tags) => tags.filter((t) => t.id !== tag.id));
}
}Options
Prop
Type
Pitfalls
What's next
Select
A single- or multiple-choice dropdown built on the accessible @angular/aria combobox/listbox primitives, with a fully custom trigger and option rendering when the default chrome doesn't fit.
Tag Input
A free-text field that turns what is typed into removable tags — comma or Enter adds one, Backspace removes the last, duplicates are ignored.