Atomic Angular — Doc interne

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

Debounced backend search with tag-based applied values
sample.component.ts
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

types, debounced loader(query) suggestions picks suggestion / presses Enter picked.emit backend call + error handling updates re-render tags, exclude from suggestions clicks tag remove removed.emit User AutocompleteComponent host-provided loader host component selected input

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.

Attaching and detaching labels with error handling
label-picker.component.ts
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.

Picking people, each with a role
people-picker.component.ts
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.

Creating a tag that doesn't exist yet
tag-creator.component.ts
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

On this page