Autocomplete
Search-as-you-type suggestions, grouped by category, with the debouncing and cancellation that keep them in order.
Suggestions come from an autocomplete web service the administrator configured: which sources feed it, how many characters trigger it, whether results are grouped. Your side is a debounced call and a list.
Concept — a suggest query
The autocomplete service draws on named suggest queries — a dictionary of terms, a list of people, past
searches. Each suggestion comes back tagged with the category it came from, which is what lets you group them
under headings.
Two calls, two purposes
Prop
Type
A Suggestion carries display (what to show), category (what to group by), and optionally frequency,
id and normalized.
A search box with suggestions
The whole difficulty is that the user types faster than the server answers. Debounce the input, and ignore any reply that is no longer current — otherwise suggestions for a prefix overwrite those for the full word.
import { createAtomicClient, sysLang } from "@sinequa/atomic";
import type { Suggestion } from "@sinequa/atomic";
const client = createAtomicClient({
app: "<your-app>",
backendUrl: "https://<your-sinequa-server>",
});
await client.initializeConfig();
await client.auth.login();
// The autocomplete service to use is configuration, not a constant to invent.
const app = await client.api.app.get();
const suggestService = app.autocomplete;
let timer: ReturnType<typeof setTimeout> | undefined;
let generation = 0;
export function onInput(text: string, render: (groups: Map<string, Suggestion[]>) => void) {
clearTimeout(timer);
// Below a couple of characters the server matches nearly everything.
if (text.length < 2) {
render(new Map());
return;
}
timer = setTimeout(async () => {
// Each request claims a generation; a late reply from an older one is dropped.
const mine = ++generation;
const suggestions = await client.api.suggest.get(suggestService, text);
if (mine !== generation) return;
render(groupByCategory(suggestions));
}, 150);
}
function groupByCategory(suggestions: Suggestion[]) {
const groups = new Map<string, Suggestion[]>();
for (const suggestion of suggestions) {
// The category is a raw, possibly multilingual label.
const heading = sysLang(suggestion.category ?? "", navigator.language) || "Other";
const list = groups.get(heading) ?? [];
list.push(suggestion);
groups.set(heading, list);
}
return groups;
}import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from "@angular/core";
import { sysLang } from "@sinequa/atomic";
import type { Suggestion } from "@sinequa/atomic";
import { SINEQUA } from "./sinequa";
@Component({
selector: "app-autocomplete",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input
[value]="text()"
(input)="text.set(value($event))"
role="combobox"
[attr.aria-expanded]="groups().length > 0"
aria-autocomplete="list"
aria-label="Search"
/>
@if (suggestions.isLoading()) {
<p>Looking…</p>
}
<ul role="listbox">
@for (group of groups(); track group.heading) {
<li role="presentation">
<strong>{{ group.heading }}</strong>
<ul>
@for (suggestion of group.items; track suggestion.display) {
<li role="option" [attr.aria-selected]="false">
<button type="button" (click)="accept(suggestion)">{{ suggestion.display }}</button>
</li>
}
</ul>
</li>
}
</ul>
`,
})
export class AutocompleteComponent {
private readonly client = inject(SINEQUA);
readonly text = signal("");
/** The configured autocomplete service — read from CCApp, never hardcoded. */
readonly service = signal("");
readonly suggestions = resource({
params: () => ({ text: this.text(), service: this.service() }),
loader: ({ params, abortSignal }) => {
// Below two characters the server matches nearly everything.
if (!params.service || params.text.length < 2) return Promise.resolve([]);
// `resource` aborts the previous load itself, so a stale reply is discarded
// without a generation counter.
abortSignal.throwIfAborted();
return this.client.api.suggest.get(params.service, params.text);
},
});
readonly groups = computed(() => {
const byHeading = new Map<string, Suggestion[]>();
for (const suggestion of this.suggestions.value() ?? []) {
const heading = sysLang(suggestion.category ?? "", navigator.language) || "Other";
byHeading.set(heading, [...(byHeading.get(heading) ?? []), suggestion]);
}
return [...byHeading].map(([heading, items]) => ({ heading, items }));
});
accept(suggestion: Suggestion) {
this.text.set(suggestion.display);
}
value(event: Event) {
return (event.target as HTMLInputElement).value;
}
}import { sysLang } from "@sinequa/atomic";
import type { Suggestion } from "@sinequa/atomic";
import { useEffect, useMemo, useState } from "react";
import { ready, sinequa } from "./sinequa";
export function Autocomplete({ onAccept }: { onAccept: (value: string) => void }) {
const [text, setText] = useState("");
const [service, setService] = useState("");
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
// The autocomplete service to use is configuration, not a constant to invent.
useEffect(() => {
ready.then(() => sinequa.api.app.get()).then((app) => setService(app.autocomplete));
}, []);
useEffect(() => {
// Below two characters the server matches nearly everything.
if (!service || text.length < 2) {
setSuggestions([]);
return;
}
let active = true;
const timer = setTimeout(async () => {
const next = await sinequa.api.suggest.get(service, text);
// The user types faster than the server answers: drop a stale reply.
if (active) setSuggestions(next);
}, 150);
return () => {
active = false;
clearTimeout(timer);
};
}, [text, service]);
const groups = useMemo(() => {
const byHeading = new Map<string, Suggestion[]>();
for (const suggestion of suggestions) {
const heading = sysLang(suggestion.category ?? "", navigator.language) || "Other";
byHeading.set(heading, [...(byHeading.get(heading) ?? []), suggestion]);
}
return [...byHeading];
}, [suggestions]);
return (
<>
<input
value={text}
onChange={(event) => setText(event.target.value)}
role="combobox"
aria-expanded={groups.length > 0}
aria-autocomplete="list"
aria-label="Search"
/>
<ul role="listbox">
{groups.map(([heading, items]) => (
<li key={heading} role="presentation">
<strong>{heading}</strong>
<ul>
{items.map((suggestion) => (
<li key={suggestion.display} role="option" aria-selected={false}>
<button type="button" onClick={() => onAccept(suggestion.display)}>
{suggestion.display}
</button>
</li>
))}
</ul>
</li>
))}
</ul>
</>
);
}Completing a filter value instead
When the user is filling a facet rather than the search box, suggest.field returns matching values of
columns:
const authors = await client.api.suggest.field("smi", ["authors"]);
// → suggestions whose display is an actual value of the authors column
query.filters = filter.eq("authors", authors[0].display);Passing the current query scopes the suggestions to the result set in view, so you offer values that will
actually match something.
What the configuration decides
CCAutocomplete — reachable as app.webServices[app.autocomplete] — carries the behaviour you should respect
rather than reinvent: whether autocomplete is enabled, which suggestQueries feed it, the
inputLengthTrigger below which nothing should be requested, and groupSuggestionsByCategory.
Reading inputLengthTrigger instead of hardcoding two characters is the difference between an interface that
follows the deployment and one that fights it.