@magicx-eng/ai-autocomplete-vanilla
v0.26.0
Published
AI Autocomplete — framework-agnostic vanilla JS library
Maintainers
Readme
@magicx-eng/ai-autocomplete-vanilla
A framework-agnostic vanilla JS/TypeScript library that provides a guided AI-powered autocomplete experience with pill-based input and dropdown suggestions. Works with any tech stack — React, Vue, Svelte, Angular, or plain HTML.
Features
- Three tiers of integration — full drop-in component, dropdown-only, or fully headless
- Zero dependencies — no React, no framework, no virtual DOM
- Rich inline input — a single
contentEditablesurface: typed text, bold completed params, and inline pills all share one editing context - Pill-based input — non-editable inline pills for unfilled parameters, bold inline text for completed ones
- Instant exact-match bolding — typing the full text of an option immediately promotes it to a completed param (no debounced fetch wait). Works in the normal typing flow and while re-editing an existing completed param.
- Re-edit completed params — tap a bold completed param to replace it; the dropdown re-opens with the cached options the server originally returned for that param
- Light/dark mode — built-in themes with
prefers-color-schemesupport, fully overridable via CSS variables - Access token auth — short-lived tokens with automatic refresh, single-flight deduplication, and 401 retry
- Keyboard navigation — arrow keys, enter to submit, tab to autocomplete, backspace to un-bold the last completed param
- Client-side filtering — instant substring filtering on every keystroke
- Datepicker — date parameters are answered with a calendar instead of an option list. Click a day or navigate with the arrow keys; the date is committed the way it would be written —
Tuesdayfor a date inside the next week,March 23for one later this year,March 23 2027for another year. Tapping a committed date re-opens the calendar on the month that text names now — for a weekday name, that is the next such day, not the one originally picked. A parameter whose options are written as spans (September 11 - October 13) gets the same calendar answered in two clicks, committed as one range. - Option overrides — supply the options for a parameter yourself: a fixed list, a computed one, or one fetched from your own search endpoint as the user types
- Product strip — the dropdown renders a horizontal row of product cards below the options, filled by default with the items the
/suggestresponse reports as matching the query; plug in your own product search instead withproducts, or turn the strip off withshowProducts: false - Catalog report — every response says which of the query's constraints the catalog applied, which it had to drop, and how many items are left, on
customFields/custom_fields - Controlled & uncontrolled — works out of the box or integrates with external state
- Accessible — ARIA combobox 1.2 pattern with
role="listbox",aria-activedescendant - IME-safe — composition events are buffered so input text is committed once, after composition ends
- Animations — option press (the picked option compresses while the rest step back), text shimmer on newly added params
- Loading skeleton — while a fetch is in flight, the dropdown and inline pills keep the previous layout (same count and widths) with their text masked and a shimmer pulse. The skeleton is held back until the option-press animation finishes, so taps don't visually "stutter" into loading.
- Lightweight — ~10 KB gzipped, styles auto-injected at runtime
- TypeScript first — full type definitions shipped with the package
- Shadow-DOM ready — mount the container inside a shadow root and the styles and caret follow it there, with nothing to configure; page CSS can't reach the widget
- SSR-safe — no top-level
document/windowaccess
Installation
pnpm add @magicx-eng/ai-autocomplete-vanillaThree Tiers
| Tier | What you get | What you own | Use when | |---|---|---|---| | Tier 1: Full | Input + dropdown + pills + keyboard + state | Nothing — drop in and go | You want a complete widget with zero setup | | Tier 2: Dropdown | Dropdown + pills + keyboard + state | The input element | You need a custom input (e.g. rich text, search bar) | | Tier 3: Headless | State + controllers + derived data | All DOM | You're building a framework wrapper (React, Vue, Svelte) or need full rendering control |
Tier 1: Full Component
Drop-in. The library creates and owns the input, dropdown, pills, and all DOM.
import { AIAutocomplete } from "@magicx-eng/ai-autocomplete-vanilla";
const ac = new AIAutocomplete(document.getElementById("root"), {
apiConfig: {
endpoint: "https://api.example.com/ac/suggest",
apiKey: "your_api_key",
},
onSubmit: (result) => {
console.log(result.query); // "Create a email"
console.log(result.raw_query); // "Create a {{TASK_1}}"
console.log(result.completed_params); // [{ placeholder: "{{TASK_1}}", type: "task", ... }]
},
});No CSS import needed — styles are auto-injected on first instantiation.
Options
const ac = new AIAutocomplete(container, {
// Tier: "full" (default) | "dropdown" | "headless"
renderMode: "full",
// API
apiConfig: { apiKey: "...", authScheme: "Bearer", endpoint: "https://api.ai-autocomplete.com/api/suggest" },
optionOverrides: { location: async (query, signal) => [...] }, // see "Option Overrides"
columns: 2,
maskCompletedText: false, // when true, omits completed params' text from API requests (PII masking)
additionalContext: { tier: "gold" }, // optional user context, to personalize suggestions and options
// Appearance
mode: "auto", // "light" | "dark" | "auto"
optionsPosition: "below", // "above" | "below"
animations: true, // press, shimmer, typed placeholder, staggered options, scroll arrow
pillPlacement: "dropdown", // "inline" | "dropdown" | "hidden"
dropdownTrigger: "auto", // "auto" | "manual" | "hidden"
closeDropdownOnBlur: true, // false = keep dropdown open even when input loses focus
showNonTappableOptions: true, // false = hide non-tappable options from the dropdown
showSkipButton: true, // false = hide the pill bar's trailing "skip" button
showOptionIcons: true, // false = render option rows as text only (no icons)
showChipIcons: true, // false = render completed chips as text only (no icons)
// Focus
autoFocus: true, // focus the input on mount (Tier 1 only)
// Where the stylesheet is injected. Defaults to the container's own root —
// the document, or the shadow root when the widget is mounted in one — so
// this is only for sending the styles somewhere else. See "Shadow DOM".
styleRoot: undefined,
// Product strip. Shown by default from the items the /suggest response
// reports; false renders no strip.
showProducts: true,
// How the strip lays its cards out: a scrolling row, or "list" — the same
// cards stacked as full-width rows.
productsLayout: "row",
// Custom source for the strip. Omit it and the server's items are shown.
products: {
fetch: (query, signal) => myPlatform.search(query, { signal }),
transform: (raw) => mapToProducts(raw),
limit: 8,
},
// Custom submit button (Tier 1 only)
// - undefined (default): renders the built-in arrow button
// - null: no submit button at all
// - HTMLElement: replaces the default button. Click events bubble up and trigger submit.
submitButton: undefined,
// Events
onSubmit: (result) => { ... },
onResult: (result) => { ... }, // after every successful round-trip — see "Reading the query as it's built"
onError: (error) => { ... },
onChange: (text) => { ... },
onParamsChange: (params) => { ... },
onFocus: () => { ... },
onBlur: () => { ... },
onProductSelect: (product) => { ... }, // see "Product strip"
// Controlled mode
value: "initial text",
completedParams: [],
});Methods
ac.focus(); // Focus the input
ac.blur(); // Blur the input
ac.reset(); // Clear everything, re-fetch, and start a new session
ac.destroy(); // Remove DOM, listeners, timers
ac.setMode("dark"); // Switch color mode
ac.update({ animations: false, optionsPosition: "above" });
ac.selectProduct(product); // Emit onProductSelect (Tier 3 / custom strips)
ac.getResult(); // The AutocompleteResult as it stands right nowTier 1 auto-resets after both Enter key and built-in submit-button clicks — your
onSubmitruns, then the SDK clears the input and starts a new session. You don't need to callac.reset()yourself in Tier 1.
autoFocus,focus(), andblur()only operate on the contentEditable editor the library owns (Tier 1 /renderMode: "full"). In Tier 2/3, the consumer owns the input and calls.focus()/.blur()on their own element; theonFocus/onBlurcallbacks still fire wheneversetFocused()is called.
Backspace into a completed param — pressing Backspace while the caret is inside (or immediately after) a bold completed param drops the param's "completed" status and removes one grapheme before the caret. The remaining text stays in the editor as plain (un-bold) text so the user can keep editing instead of losing the whole phrase.
Re-edit a completed param — tapping a bold completed param enters re-edit mode. The dropdown re-opens with the cached options the server originally returned for that param. Typing atomically replaces the bold (and re-promotes it to bold if what you type exactly matches one of the cached options); clicking an option replaces it with the new selection; pressing Escape or moving the caret out exits without changes.
Caret placement after a completion — whenever a completed param is added (by any means — option click, exact-match typing, or re-edit), the caret lands right after the trailing space following the bold so typing can continue immediately. A space is inserted if one wasn't already there.
Product strip
The dropdown renders a horizontal row of product cards below the options grid
whenever it has products to show. By default those are the items the
/suggest response itself reports as matching the query — no configuration,
no extra request: the cards land in the same state write as the suggestions,
so what the strip shows and what the pills offer always describe the same
narrowing.
import { AIAutocomplete } from "@magicx-eng/ai-autocomplete-vanilla";
new AIAutocomplete(container, {
apiConfig: { apiKey: "..." },
// Selection emits — the SDK never navigates.
onProductSelect: (product) => {
if (product.url) window.location.assign(product.url); // …or add to cart, or fill the input
},
});Each server item becomes a Product with id (the item's stable id), title,
price (the item's lowest price, verbatim as the catalog spells it — the
currency is yours to add), vendor, imageUrl, and a url: the item's own
item_url when the uploaded catalog named one (absolute or root-relative;
anything else is ignored), else a relative /products/{handle} — the right
link for a Shopify widget running on the storefront itself. An item with
neither gets no url: its card is still activatable and still emits
onProductSelect, it just has nothing for cmd-click to open. productFromMatchedItem / productsFromCustomFields are
exported for a hand-rolled strip that wants the same mapping.
Set showProducts: false to render no strip at all. The catalog report
(below) stays readable either way.
productsLayout: "list" stacks the same cards as full-width rows — thumbnail
on the left, title, vendor and price on the right — for results that read as
a list (courses, listings, documents) rather than products on a shelf. The
column scrolls vertically inside --aia-product-list-max-height (320px by
default); --aia-product-list-media-size (44px) sizes the thumbnail and
--aia-product-list-gap (2px) the space between rows. "row", the default,
is the horizontal strip. update({ productsLayout }) re-lays the strip out at
once.
The catalog report (customFields)
Every response that read the catalog also says what it did with the query, and
the SDK exposes that as customFields on state (and as custom_fields on
AutocompleteResult, so onResult / getResult() carry it too):
ac.subscribe((state) => {
const report = state.customFields;
if (!report) return; // no catalog was read for this response
count.textContent = `${report.items.total} results`;
if (report.dropped_filters.length > 0) {
// e.g. { param: "Color", value: "Chartreuse", op: "eq" } — the shopper asked
// for a colour no item carries, so the results ignore it. Say so.
note.textContent = `No match for ${report.dropped_filters.map((f) => f.value).join(", ")}`;
}
});applied_filters/dropped_filters— the constraints the server read out of the query as{ param, value, op }(opis one ofeq,ne,lt,lte,gt,gte), split into the ones the catalog answered and the ones it could not (a parameter it does not have, a bound that is not a number, a value no item carries). The items answer the applied set, so the dropped list is what tells "87 black t-shirts" from "no black item, so here are t-shirts". Both are always arrays.items.total— how many items the applied filters left standing. It also moves with the query's own words, so it drops as a typed word starts matching a title.items.matchedis the list the strip renders, capped by the server; it is empty (with a non-zerototal) when nothing narrowed the catalog, where listing arbitrary items as matches would mislead.nullmeans the response carried no report — no catalog was read for it (a product with no catalog, the starting state, a response with no option-bearing parameter to offer). That is "no news", not an empty catalog. The strip clears on such a response rather than keeping an older query's items.
Your own product search (products)
To source the cards from your own search instead — a platform with its own
endpoint and response shape — set products. The SDK owns the UI; you own
only the fetching and the mapping. The server's items are then not rendered
(they stay readable on customFields).
import { AIAutocomplete, type Product } from "@magicx-eng/ai-autocomplete-vanilla";
new AIAutocomplete(container, {
apiConfig: { apiKey: "..." },
products: {
// You own the request entirely — auth headers, GraphQL body, locale
// prefixes, whatever the platform needs. Honour the signal: the SDK aborts
// it as soon as a newer query supersedes this one.
fetch: (query, signal) =>
fetch("/api/2024-10/graphql.json", {
method: "POST",
headers: { "X-Shopify-Storefront-Access-Token": token },
body: JSON.stringify({ query: SEARCH_QUERY, variables: { q: query } }),
signal,
}).then((r) => r.json()),
// Pure mapping, kept out of `fetch` so you can unit-test it without a
// network. Fold it into `fetch` and return the array if you prefer.
transform: (raw) =>
raw.data.products.nodes.map((node) => ({
id: node.id,
title: node.title,
url: node.onlineStoreUrl,
imageUrl: node.featuredImage?.url ?? null,
price: formatMoney(node.priceRange.minVariantPrice), // you know the currency
vendor: node.vendor,
})),
limit: 8, // applied by the SDK, after transform
},
onProductSelect: (product) => {
if (product.url) window.location.assign(product.url);
},
});Product is exactly six fields; only id and title are required:
type Product = {
id: string;
title: string;
url?: string; // absent = a card with no link (still activatable)
imageUrl?: string | null; // null renders the placeholder tile
price?: string; // pre-formatted by you
vendor?: string;
};What the SDK guarantees
- One fetch cadence. The product search rides the same debounce, the same
AbortControllerand the same version guard as/suggest— there is no second timer to drift out of step. A query that never triggers a suggestions request (e.g. one still narrowing the current option list) doesn't trigger a product search either. - Empty queries never reach you. On mount and after
reset()the strip simply clears. - Out-of-order responses are dropped. A slow response for an older query is never rendered over a newer one.
- Failure is silent. A rejected
fetchor a throwingtransformclears the strip, logs once, and leaves the suggestions half untouched — noerrorstate, noonError, no effect onisLoading. - The two halves are independent. The panel opens if either has content,
so products keep it open when option filtering empties the list, and
suggestions render normally when there are no products.
dropdownTrigger(manual/hidden) still gates the panel as before — products don't overrule an explicit opt-out of an auto-opening panel.
Selection and links. Cards are real <a href> elements, so cmd/ctrl-click,
middle-click and "copy link address" behave natively. A plain left click is
intercepted (preventDefault) and emits onProductSelect instead — the SDK
never navigates on your behalf.
product.url is used verbatim — the SDK trusts your transform output and
does not sanitise it, so validate the URL there if the platform response isn't
fully under your control. (Angular additionally runs its own href
sanitiser, so unrecognised schemes like myapp://… are rewritten to
unsafe:… in that package only.)
Accessibility. The strip is a role="group" labelled "Products"; each card
is a role="option" in the tab order, activated with Enter or Space, with a
visible focus ring. Tabbing into the strip does not close the panel. The row
scrolls horizontally by trackpad, wheel, touch and keyboard; the page never
scrolls sideways and the scrollbar chrome is hidden in all engines.
update({ products }) swaps the integration and clears the current cards; the
strip repopulates on the next /suggest request, since the product search
rides that one scheduler and changing the config doesn't itself change the
query. update({ products: undefined }) hands the strip back to the server's
items at once, and update({ showProducts }) empties or restores it at once.
Custom submit button
const myButton = document.createElement("button");
myButton.className = "my-button";
myButton.textContent = "Go";
new AIAutocomplete(container, {
apiConfig: { apiKey: "..." },
onSubmit: (result) => { ... },
submitButton: myButton, // replaces the default; clicks trigger submit
});
// Or hide the button entirely:
new AIAutocomplete(container, { ..., submitButton: null });Datepicker
When the next parameter is a date, the dropdown shows a calendar instead of a
list of options. A parameter counts as a date when its name says so, or when at
least three of the options it offers are themselves written as dates — so a
parameter called when or arrival still gets a calendar. Options written as
relative phrases (today, next week) are not read as dates; a parameter
offering those needs a name that says date.
Nothing is required to switch it on — a date parameter renders this way automatically, and every other parameter is unaffected. Tier 1 and Tier 2 render the calendar for you; in Tier 3 the same data reaches your own renderer (see Tier 3).
What the user can do
| Input | Result | |---|---| | Click a day | Commits that date | | ↓ | Moves into the calendar, starting on today | | ← / → | Previous / next day, crossing week boundaries | | ↑ / ↓ | Previous / next week. Past either end of the month, focus returns to the input | | Enter | Commits the highlighted day | | Month arrows | Page the calendar. Paging never commits anything | | → at the end of the input | Skips the parameter, same as any other pill |
The committed value is written the way a person would write it, always in English regardless of the visitor's locale, so the value you receive has one vocabulary everywhere:
- a date inside the next week (today included) commits as its weekday name —
Tuesday. Like the sentence it sits in, that name is relative: read later, it means the next such day; - one further out in the current year as
MONTH DAY—March 23; - one in another year as
MONTH DAY YEAR—March 23 2027.
It arrives as an ordinary completed parameter: bold in the input,
and present in completed_params on submit like any other answer.
Re-editing a committed date re-opens the calendar on the month the committed text names, with that day marked, so changing an answer takes one click. For a weekday name that reading is relative, so once the day it named has gone by, re-editing marks the next such day rather than the one originally picked — the calendar shows what the sentence says today, which is also what gets submitted.
Reading the value back. The date arrives as text, so if you need a Date
object, parseDate is exported for it:
import { parseDate } from "@magicx-eng/ai-autocomplete-vanilla";
// Reads all three committed shapes: a weekday name is the one date it can
// mean in the next seven days, and a yearless month-day is the current year.
const due = parseDate("March 23"); // Date, or null if the text isn't one of oursparseLooseDate is exported too, for the looser shapes a person types into a
query — it reads september 5th, 5th September, Sept 5 and 2026-09-05,
and answers null for anything ambiguous rather than guessing.
Dates the user writes themselves. When someone types a date into their query — "fly to vegas on september 5th" — the parameter it answers is recognised in place and the date becomes tappable. Tapping it opens the calendar on that month with the day already marked, so confirming or changing it takes one tap, and the picked date replaces their words with the canonical form.
The written date is read where it can be: september 5th, 5th September,
Sept 5, September 5, 2026 and 2026-09-05 all resolve, in any of those
orders and with or without the year (a date with no year means its next
occurrence). Two cases deliberately don't pre-select — a numeric date like
09/05, which is September 5th in the US and May 9th elsewhere with nothing to
say which, and phrases like next friday. Those still open the calendar, just
on the current month with nothing marked, so the user picks rather than being
shown a guess that might be wrong.
Date ranges. A parameter whose options are written as spans — `September 11
- October 13
,September 11 to November 16` — is answered by the same calendar in two clicks: the first sets the start, the second the end, and the pair commits as one answer. The days between them band as the user moves across the calendar, so the span being chosen is visible before it is committed. Paging the month between the two clicks is expected — that is how a span crosses months — and Esc drops the start and lets them begin again. Clicking the end before the start is fine; the two are ordered on the way in.
The span is read off the options the same way a single date is read off its own:
either separator (-, –, —, to, through, until), either half in any
date format the SDK reads, and a name that says so (date_range, dateRange)
counts too. It commits as one parameter, written the way it was offered —
September 11 - October 13, with the year on whichever end needs it — and both
ends are also recorded on the completed parameter's metadata as YYYY-MM-DD,
under the DATE_RANGE_META_START / DATE_RANGE_META_END keys this package
exports (aiaDateRangeStart / aiaDateRangeEnd), so you never have to parse
the prose. Re-editing one re-opens the calendar with both ends marked.
Typing while the calendar is open does not filter it. The text is treated as a new query, so suggestions refresh as the user types — useful when someone would rather describe what they want than pick a day.
Tier 2: Dropdown Only
You own the input. The library renders the dropdown with pills inside a container you provide.
import { AIAutocomplete } from "@magicx-eng/ai-autocomplete-vanilla";
const input = document.getElementById("my-input");
const dropdownContainer = document.getElementById("my-dropdown");
const ac = new AIAutocomplete(dropdownContainer, {
renderMode: "dropdown",
apiConfig: { apiKey: "your_api_key" },
onChange: (text) => {
// Keep your input in sync when the library updates text (e.g. option selected)
if (input.value !== text) input.value = text;
},
onSubmit: (result) => console.log(result),
});
// Wire your input → library
input.addEventListener("input", () => ac.handleTextChange(input.value));
input.addEventListener("keydown", (e) => ac.handleKeyDown(e));
input.addEventListener("focus", () => ac.setFocused(true));
input.addEventListener("blur", () => ac.setFocused(false));Pills always render inside the dropdown in this mode. The library creates the dropdown DOM inside your container — you just provide the element and wire up your input's events.
The product strip works here too: it renders inside the dropdown the library owns, from the server's items by default, and products / showProducts / onProductSelect behave exactly as in Tier 1.
Focus/blur wiring is required when
dropdownTriggeris"auto"(the default) — the dropdown only opens while the input is focused. WithoutsetFocused(), the dropdown will never open.
If your input is a scrollable contenteditable rather than the <input> above, see Keeping the caret visible — placing the caret is yours to do in this mode, and so is scrolling to it.
Tier 3: Headless
No DOM at all. You get state, controllers, and derived data. You render everything yourself.
This is what framework wrappers (React, Vue, Svelte) use under the hood.
import { AIAutocomplete } from "@magicx-eng/ai-autocomplete-vanilla";
const ac = new AIAutocomplete(document.createElement("div"), {
renderMode: "headless",
apiConfig: { apiKey: "your_api_key" },
onSubmit: (result) => console.log(result),
});
// Read state
const state = ac.getState();
// state.text, state.completedParams, state.suggestions,
// state.segments, state.filteredOptions, state.actionableSuggestions,
// state.placeholderText, state.isDropdownOpen, state.activeDropdownIndex,
// state.newParamId, state.isLoading, state.isReady, state.error
// Subscribe to state changes. The listener receives the full state shape
// (raw inputs + derived fields like `segments`, `actionableSuggestions`,
// `filteredOptions`, `placeholderText`, `isDropdownOpen`).
const unsub = ac.subscribe((state) => {
renderMyInput(state.text, state.placeholderText);
renderMyPills(state.actionableSuggestions);
renderMyDropdown(state.filteredOptions, state.activeDropdownIndex, state.isDropdownOpen);
renderMySegments(state.segments, state.newParamId);
});
// Forward user actions → library
myInput.addEventListener("input", () => ac.handleTextChange(myInput.value));
myInput.addEventListener("keydown", (e) => ac.handleKeyDown(e));
myInput.addEventListener("focus", () => ac.setFocused(true));
myInput.addEventListener("blur", () => ac.setFocused(false));
myOptionEl.addEventListener("click", () => ac.selectOption(option));
myPillEl.addEventListener("click", () => ac.setActivePill(index));
// Cleanup
ac.destroy();
unsub();Custom (rich-text) editors: the wiring above assumes
myInputis a<textarea>/<input>, whose caret the library reads directly. If you're driving a contentEditable or rich-text editor instead, also callac.handleCaretMove(offset)on selection changes (with the caret as a plain-text offset) so arrow keys can move from the input into the dropdown.
Datepicker in a custom UI. For a date parameter,
state.activeFormatTypeis"date"andstate.filteredOptionsholds that month's day cells. Each cell'stextis the date it commits ("March 23", or"Tuesday"inside the next week), so rendering them as a plain list already works —selectOption(cell)behaves exactly as it does for an option. To draw an actual calendar, readstate.dateViewfor the month on show,cellDay(cell)for the number to paint, and callshowPreviousMonth()/showNextMonth()to page. Cells that pad the start and end of the month haveis_tappable: false. For a range parameteractiveFormatTypeis"date-range"and the sameselectOption(cell)is called twice: the first records the start (state.dateRangeStartholds it, and nothing is committed yet), the second commits the span.dateCellMarksandvisibleDateRangeare exported to paint the two ends and the band between them the way the built-in calendars do.
Option icons. An option may carry an icon:
icon_svgis inline SVG markup andiconits name. The built-in dropdown draws the SVG before the option's text, in the text color, and hides it while the row shows its loading skeleton. When rendering options yourself, passicon_svgthrough the exportedsanitizeOptionIconSvgbefore inserting it — it reduces the markup to plain vector drawing and returnsnullfor anything else — and useoptionLabel(option)for the text — it is the option'stextalone;iconis the icon's name and is never shown as text, even whenicon_svgis missing or fails to draw. A picked option'sicon/icon_svgare copied onto its completed parameter, and the chip in the input draws the glyph before its text. Both surfaces can be switched off:showOptionIcons: falserenders option rows as text only andshowChipIcons: falserenders chips as text only; either way the icon element is omitted rather than hidden.
State shape (CoreState)
| Field | Type | Description |
|---|---|---|
| text | string | Current input text |
| completedParams | CompletedParamState[] | Filled parameters |
| suggestions | Suggestion[] | All suggestions from server (including placeholder type) |
| actionableSuggestions | Suggestion[] | Non-placeholder suggestions (the pills) |
| filteredOptions | SuggestionOption[] | Options for the active suggestion, filtered by current query. For a date parameter these are the visible month's day cells instead — each cell's text is the date it commits ("March 23", or "Tuesday" inside the next week), and cells padding the start/end of the month have is_tappable: false. |
| activeFormatType | "options" \| "date" \| "date-range" | How the active parameter is answered: pick an option, pick a date, or pick a span of dates. Both date formats show a calendar instead of the option list; "date-range" takes two clicks, a start and an end. Typing does not filter it — the text is treated as a new query. |
| dateView | DateMonthView \| null | The month the calendar is showing ({ year, month }, 0-based month). Null unless activeFormatType is a calendar one. |
| dateRangeStart | string \| null | While a range is half-picked, the start already chosen, as YYYY-MM-DD. Null at every other moment, including for every non-range parameter. |
| segments | Segment[] | Input text split into typed text vs completed params — completed segments render as bold <strong> runs inside the editor |
| placeholderText | string | Placeholder text from server suggestions (joined placeholder-type suggestion texts) |
| activeDropdownIndex | number | Highlighted option index (-1 = none) |
| isDropdownOpen | boolean | Whether the dropdown should be visible |
| newParamId | string \| null | ID of the most recently added param (for shimmer animation) |
| isLoading | boolean | Fetch in progress. The Tier 1 / Tier 2 renderers gate the loading skeleton additionally on !inSelectionAnimation and !editingParam. |
| optionQuery | string | The phrase the active parameter's options are filtered by — what the user has typed for it, trimmed — or the replacement typed so far during a re-edit. This is what an option override is asked with. |
| isSearchingOptions | boolean | True while an option override for the parameter on screen has been asked and hasn't answered. Render the same skeleton you render for isLoading; the built-in dropdowns do. |
| inSelectionAnimation | boolean | True for the 500 ms after a user-initiated option tap so the press animation can finish before the dropdown switches to the loading skeleton. |
| editingParam | CompletedParamState \| null | When non-null, the user is re-editing a bold completed param; cached options remain visible and the loading skeleton is suppressed. |
| products | Product[] | What the product strip shows: the items the latest response reported as matching the query, or the results of a custom products search. See Product strip — Tier 3 consumers render their own cards and call selectProduct(product) to emit onProductSelect. |
| customFields | CustomFieldsReport \| null | The catalog report the latest response carried — applied and dropped filters, and the matching items' count and list — or null when it carried none. See The catalog report. |
| isReady | boolean | Server indicates query is complete |
| error | Error \| null | Last fetch error |
Actions
| Method | Description |
|---|---|
| handleTextChange(value) | Call when user types. Handles capitalization, param reconciliation, filter updates. |
| showPreviousMonth() / showNextMonth() | Page the calendar a month at a time; paging never commits anything. Tier 1 and Tier 2 wire the built-in month arrows to these, so you only need them for a hand-rolled calendar. No-ops unless the active parameter is a date. Moving on to a different parameter resets the calendar to the current month. |
| handleKeyDown(event) | Forward keyboard events. Handles arrow nav, Enter, Tab, Escape. |
| setFocused(focused) | Notify the library that the input has focus. Required for dropdownTrigger: "auto" in Tier 2/3 — the dropdown only opens while focused. |
| handleCaretMove(offset) | Report the caret position (plain-text offset). For a custom editor that isn't a <textarea>/<input> (e.g. contentEditable / rich-text), this is what lets arrow keys move into the dropdown. The library reads a <textarea>/<input>'s caret directly, so you only need this for custom editors. |
| selectOption(option) | Select a dropdown option. Updates text, creates completed param, triggers shimmer. |
| setActiveDropdownIndex(index) | Set the highlighted option (for mouse hover). |
| setActivePill(index) | Reorder pills — moves pill at index to front (active). |
| skipActivePill() | Skip the active pill — same action as → / the dropdown's skip button. Records it in skippedParams; no-ops during re-edit and the post-selection window. Use it to drive a custom skip affordance (e.g. with showSkipButton: false). |
| removeLastParam() | Remove the last completed param from state. The text stays in the input as plain text. |
| clearNewParamId() | Clear shimmer animation state. |
| reset() | Clear all state, re-fetch initial suggestions, and start a new session (rotates session_id). Call this after handling submit. |
| setValue(text) | Set text (controlled mode). |
| setCompletedParams(params) | Set completed params (controlled mode). |
| setMode(mode) | Switch color mode at runtime. |
| update(opts) | Update multiple options at once (e.g. animations, pillPlacement, dropdownTrigger, optionsPosition). |
| getState() | Get a snapshot of the current state. Useful for initial read before subscribing. |
| subscribe(listener) | Subscribe to state changes. The listener receives the full state (raw inputs + derived fields). Returns unsubscribe function. |
| destroy() | Cleanup — abort fetches, clear timers, remove listeners. |
Building a framework wrapper
The pattern for any framework:
- Create a headless instance on mount
- Subscribe to state → map to framework reactivity (
useState,ref(),writable()) - Render your own components using the state
- Forward user events (input, keydown, click) → core actions
- Destroy on unmount
Keeping the caret visible
Tier 1 handles this for you. In Tiers 2 and 3 you own the editor, so if you
render your own contenteditable that can scroll — held to one line with
overflow-x, or capped in height — call scrollCaretIntoView after you move
the caret yourself:
import { scrollCaretIntoView } from "@magicx-eng/ai-autocomplete-vanilla";
// ...right after placing the caret
scrollCaretIntoView(editorElement);A browser scrolls an editor to the caret while the user types, but not when the caret is moved by script — so answering a parameter can otherwise leave the caret, and everything typed next, outside the visible box.
It scrolls only the nearest scrollable box around the element you pass, and
never the page. It is a no-op when nothing overflows, when the caret is already
visible, and when the caret is not inside the element you pass — which includes
a plain <input> or <textarea>, whose caret the document selection cannot
reach. Those are the browser's to scroll, not this helper's.
API Reference
APIConfig
A discriminated union: APIKeyConfig | AccessTokenConfig.
API Key Mode (default)
{
apiKey: "your_api_key",
authScheme: "Bearer", // or "Basic"
endpoint: "https://api.ai-autocomplete.com/api/suggest", // optional, this is the default
appIdentifier: "my-app", // optional
headers: { "X-Custom": "v" }, // optional
}Access Token Mode
{
type: "accessToken",
getAccessToken: async () => {
const res = await fetch("/api/ac-token");
const { access_token, expires_at } = await res.json();
return { accessToken: access_token, expiresAt: expires_at };
},
endpoint: "https://api.ai-autocomplete.com/api/suggest", // optional, this is the default
}The SDK handles token refresh transparently: if a request returns 401, it calls getAccessToken once, retries, and only surfaces an error if the retry also fails. Concurrent 401s share a single refresh call. Tokens are refreshed proactively 30 seconds before expiresAt.
AutocompleteResult
The structured query as the SDK currently understands it. The same object is passed to onSubmit when the user submits, to onResult after every successful round-trip, and returned by ac.getResult() on demand:
| Field | Type | Description |
|---|---|---|
| query | string | Plain text as the user sees it, trimmed. |
| raw_query | string | query with each completed param replaced by its placeholder token (e.g. "Create a {{TASK_1}}"). |
| completed_params | CompletedParam[] | Filled parameter values in query order, followed by any the user skipped (see below). |
| identified_params | IdentifiedParam[] | Parameters the server recognised in the user's own words, as { type, value } — e.g. { type: "due_date", value: "Friday" } when the user typed "by Friday" instead of picking a date. They are not tokenized in raw_query, and they are tentative: each response replaces the set, and an entry drops out as soon as its text is edited away. |
| is_ready | boolean | Whether the server considers the query complete enough to act on. |
| custom_fields? | CustomFieldsReport | What catalog grounding did for the latest applied response, when it read a catalog at all — see Product strip. applied_filters and dropped_filters list the query's constraints as { param, value, op }; items.total is how many items the applied ones left standing and items.matched the ones the strip renders. Absent (no key) when the response carried no report. |
Reading the query as it's built
You don't have to wait for submit. onResult fires after every successful round-trip with the result as it now stands, so you can mirror the structured query live — a preview panel, a draft saved as the user goes, analytics on how far they got:
const ac = new AIAutocomplete(el, {
onResult: (result) => {
preview.textContent = result.raw_query;
if (result.is_ready) enableRunButton();
},
onSubmit: (result) => run(result),
});
// Or subscribe later / more than once:
const off = ac.on("result", (result) => save(result));
// Or read it on demand, from a place that has no event handy:
const draft = ac.getResult();What "every successful round-trip" means in practice:
- It fires once per response the SDK applied — including the very first request on mount (an empty result) and after a response that promoted text the user had already typed into a completed param.
- It does not fire for a request that failed (
onErrordoes), was cancelled, or was superseded by a newer one before it returned. - Keystrokes that haven't been sent yet don't fire it. Typing is debounced and only reaches the server once at least two new characters have been added, so
onResultlags the input by a beat;ac.getResult()always reflects the input as it is right now. - A submit is the last result you saw plus whatever the user typed since. Both are built by the same exported
buildSubmitResult, so the shapes never drift.
Skipped parameters
Pressing → at the end of the input dismisses the active pill. The dismissal is reported to the server — and included in completed_params here — as an entry with no placeholder and the sentinel text "skipped":
{ placeholder: "", type: "goal", text: "skipped", kind: null }Skipped entries are appended after the filled params (they have no position in the query) and are deduped by type. A skip is dropped if a param of the same type ends up filled anyway. Skipping the last available pill triggers an immediate request so the server can suggest something else; earlier skips ride along on the next request. reset() clears them.
Reading
state.skippedParamsdirectly: the array is append-only untilreset(). The "drop a skip whose type got filled" rule is applied when the payload is built, not by pruning the array — so if the user skipsgoaland later fills one, the raw array still holds thegoalentry. That's deliberate: the filter self-heals if they then delete that param's text, where pruning would discard the signal for good. Apply the same rule yourself with the exportedwithSkippedParams(completedParams, skippedParams).
Event Subscription
const off = ac.on("submit", (result) => { ... });
off(); // unsubscribe
// Multiple listeners on the same event are supported — each `on()` call adds
// a listener; the returned function removes only the listener it registered.
const offA = ac.on("change", logToAnalytics);
const offB = ac.on("change", syncToStore);Events: submit, result, error, change, paramsChange, stateChange, focus, blur, productSelect.
result carries an AutocompleteResult after every successful round-trip — see Reading the query as it's built. Prefer it over stateChange when what you want is the structured query: stateChange fires on every internal update (focus, highlight, keystroke) and hands you raw state you would have to assemble yourself.
Errors in your callbacks are contained. A listener that throws is logged to the console (once per event per instance) and the remaining listeners still run — the SDK's own state is unaffected, so a bug in one handler can't stall the widget. The same holds for subscribe() listeners and optionOverrides functions; an override that throws or rejects is logged once per instance and treated as an empty answer, never as a fetch error. One exception is deliberate: if an onSubmit handler throws, Tier 1 skips its auto-reset so the user's typed query isn't cleared out from under a failed submit.
Constructor callbacks (onSubmit, onChange, etc.) are registered once at construction as the initial listener for that event. Use on() for any additional or replacement listeners. update() does not swap event listeners — use on() for dynamic listener management.
subscribe() vs on(): Use on() for specific events (submit, error, text change). Use subscribe() (Tier 3) for full state updates — every subscribe callback receives the merged input + derived state, ready to read.
CSS Customization
Styles are auto-injected at runtime (Tier 1 and Tier 2). No CSS import needed. The component ships built-in light and dark defaults.
Every class name the core emits is namespaced — magicx-aia-* (plus the bare magicx-aia root) and aia-* for the layout primitives — so nothing it renders can match a .container, .pill or .dropdown rule in your own stylesheet, and none of its rules can reach your elements.
Page CSS stops at the widget's edge. Everything inside the input wrapper ([data-aia-input-wrapper]) and the dropdown ([data-aia-dropdown]) is reset to browser defaults before the SDK's own styles apply, so element and universal rules in your stylesheet — button { … }, a { … }, * { … }, a CSS reset, a framework's preflight — never reach it, and inherited typography adjustments from the page (line-height, letter-spacing, text-transform, text-align, …) are pinned at those two roots. Font family, size, weight and text colour still inherit from your page; a submit button you provide is left to your own stylesheet; and nothing the SDK ships styles elements outside its own subtree. The SDK's inline icons are covered too: a page's svg { display: block; max-width: 100%; margin: … } or svg { width: 100%; fill: currentColor } leaves the submit arrow, the scroll chevron and the option icons as drawn. The reset sits at specificity (0,0,5), below any rule that names a class, attribute or id — your overrides on the data-aia-* hooks work exactly as before, and so does a page rule like .sidebar button { … }, which names a class: scope rules like that to your own markup. For complete isolation, including rules like that, mount the widget in a shadow root — see Shadow DOM.
Shadow DOM
Mount the container inside a shadow root and the widget works there — the stylesheet is injected into that root rather than document.head, and the caret is read from the root that owns the editor:
const host = document.querySelector("#autocomplete-host");
const root = host.attachShadow({ mode: "open" });
const container = document.createElement("div");
root.appendChild(container);
const ac = new AIAutocomplete(container, { apiConfig: { apiKey: "..." } });Nothing to configure: the root is derived from the container. Light-DOM mounts are unaffected, and a page can hold both — each root gets the stylesheet.
This is the way to embed the widget in a page whose CSS you don't control. In the light DOM every page rule reaches the widget, and the ones that reach it by element (footer, kbd, a) or with !important win over the SDK's own classes. A shadow root ends that: page rules stop at the boundary. Note that the same boundary stops your page CSS too, so the selector hooks below have to be applied from inside the shadow root (or through the --aia-* variables, which inherit across it — set them on the host).
styleRoot overrides where the styles land, for the rare mount whose container root isn't where they should go. It's applied once, at construction.
CSS Variables
Override these on the container element. The variables are declared with :where() (zero specificity), so a value you set on the container always wins without !important.
| Variable | Default (light) | Default (dark) | Description |
|---|---|---|---|
| --aia-font-family | inherit | inherit | Font used by the library. Defaults to inherit so the library picks up your page's font automatically. Set this to pin a specific font on the library without changing the surrounding page. |
| --aia-pill-bg | #bdbdbd | #bdbdbd | Pill background |
| --aia-pill-color | #000000 | #ffffff | Pill text |
| --aia-pill-font-size | 19px | 19px | Pill font size |
| --aia-option-bg | transparent | transparent | Option background (highlighted) |
| --aia-option-color | #000000 | #ffffff | Option text |
| --aia-option-color-selected | #000000 | #ffffff | Highlighted option text |
| --aia-option-font-size | 19px | 19px | Option font size |
| --aia-written-text-color | #000000 | #ffffff | Input text |
| --aia-written-text-font-size | 19px | 19px | Input text font size |
| --aia-caret-color | --aia-written-text-color | --aia-written-text-color | Editor caret color. Override independently of input text color. |
| --aia-submit-bg | #000000 | #ffffff | Submit button background. Solid + full-opacity at rest, even when the input is empty/disabled. |
| --aia-submit-color | #ffffff | #000000 | Submit button arrow color |
| --aia-submit-bg-disabled | --aia-submit-bg | --aia-submit-bg | Submit background when disabled (empty input). Defaults to the enabled background so themed colors aren't washed out. Set this for a faded rest state. |
| --aia-submit-color-disabled | --aia-submit-color | --aia-submit-color | Submit arrow color when disabled. Defaults to the enabled color. |
| --aia-border | rgba(17, 24, 39, 0.14) | #505050 | Input container border color |
| --aia-shadow | inset 0 1px 0 rgba(255,255,255,0.9), 0 1px 2px rgba(16,24,40,0.1), 0 8px 24px rgba(16,24,40,0.16) | inset 0 1px 0 rgba(255,255,255,0.06), 0 1px 2px rgba(0,0,0,0.4), 0 8px 24px rgba(0,0,0,0.5) | Input container elevation (box-shadow) |
| --aia-dropdown-offset | 13px | 13px | Gap between the input box and the dropdown (the dropdown's margin toward the input). |
| --aia-content-inset-left | 16px | 16px | Left inset of the SDK's content: the input wrapper's left padding, and the line the dropdown's content (parameter label, option text, product strip) starts on. Set it when you pad the field to clear a leading icon of your own, so the dropdown's content stays aligned under the typed text. Practical minimum is 10px (the option text's own inset); both surfaces floor it there. |
| --aia-footer-gap | 8px | 8px | Breathing room above the dropdown footer (badge / keyboard hints), additive to the dropdown's 8px section gap. |
| --aia-footer-chip-bg | --aia-surface at 65% | --aia-surface at 65% | Fill behind the footer's keyboard hint and AI-Autocomplete badge. The option list scrolls under the footer, so this keeps both legible over the row passing behind them. transparent on the glass surface. |
| --aia-dropdown-bg | — | — | Optional bg color the dropdown's "glass" rim shadow tints toward. Set this to the page background behind the dropdown so the bottom-corner glow blends seamlessly. |
| --aia-scrollbar-thumb | rgba(0, 0, 0, 0.3) | rgba(0, 0, 0, 0.3) | Color of the option list's scrollbar thumb (Firefox + WebKit). |
| --aia-streak-rgb | 99, 102, 241 | 255, 255, 255 | Comma-separated RGB triplet tinting the datepicker's pressed cell, and its selected cell when --aia-date-selected-bg is unset. The today ring uses --aia-date-today-ring, not this. |
| --aia-product-card-width | 116px | 116px | Width of a product card in the strip. The media tile is square, so this also sets its height. |
| --aia-product-gap | 8px | 8px | Gap between product cards. |
| --aia-product-bg | transparent | transparent | Product card background. |
| --aia-product-bg-active | --aia-option-bg | --aia-option-bg | Product card background on hover. |
| --aia-product-media-bg | --aia-skeleton-bg | --aia-skeleton-bg | Fill behind the product image, and of the placeholder tile when a product has no image. |
| --aia-scroll-arrow-color | --aia-option-color | --aia-option-color | Chevron color of the arrow (--aia-scroll-arrow-color-hover on hover). |
| --aia-placeholder-fade | 120ms | 120ms | Fade-out of the outgoing placeholder phrase when the starting-state placeholder changes (the incoming one types itself in). |
| --aia-product-placeholder-image | storefront pictogram | storefront pictogram | The image drawn on the placeholder tile when a product has no imageUrl: a two-tone storefront pictogram, inlined as a data URI. Set it to any url(...) to swap the pictogram; it is drawn centered and contained inside the tile, over --aia-product-media-bg. |
| --aia-product-title-color | --aia-option-color-selected | --aia-option-color-selected | Product title text. Follows the option colors by default, so theming the panel moves suggestions and products together. |
| --aia-product-price-color | --aia-option-color-selected | --aia-option-color-selected | Product price text. |
| --aia-product-vendor-color | --aia-option-color | --aia-option-color | Product vendor line. |
| --aia-product-focus-ring | --aia-option-color-selected | --aia-option-color-selected | Focus ring drawn on a keyboard-focused card. |
| --aia-products-label-color | --aia-option-color | --aia-option-color | "Products" section label. |
| --aia-product-title-font-size | 12px | 12px | Product title font size. |
| --aia-product-price-font-size | 11px | 11px | Product price font size. |
| --aia-product-vendor-font-size | 10px | 10px | Product vendor font size. |
| --aia-products-label-font-size | 11px | 11px | Section label font size. |
| --aia-date-panel-width | 312px | 312px | Max width of the whole dropdown panel while the calendar is up — the panel narrows to calendar size. |
| --aia-date-cell-size | 26px | 26px | Size of a day's square — the box that carries the highlight, the today ring and the selected fill. |
| --aia-date-row-height | 28px | 28px | Height of one week row. Lower it to fit a 6-week month in a shorter dropdown. |
| --aia-date-cell-font-size | 13px | 13px | Day-number font size. |
| --aia-date-month-font-size | 14px | 14px | Font size of the "March 2026" header. |
| --aia-date-weekday-font-size | 11px | 11px | Font size of the S/M/T/W/T/F/S column letters. |
| --aia-date-today-ring | --aia-option-color | --aia-option-color | Outline drawn around today's date. |
| --aia-date-selected-bg | white at 12% | white at 12% | Fill behind the date a re-edited parameter already holds. |
| --aia-date-range-bg | white at 6% | white at 6% | Band across the days between the two ends of a picked date range. The ends themselves use --aia-date-selected-bg. |
| --aia-skeleton-bg | rgba(189, 189, 189, 0.25) | #1a1b1d | Fill color for the loading skeleton bars and the masked text in cached pills/options. |
Per-mode Overrides
#my-autocomplete[data-mode="light"] {
--aia-pill-bg: #e2e8f0;
}
#my-autocomplete[data-mode="dark"] {
--aia-pill-bg: #334155;
}Live Preview
document.getElementById("my-autocomplete")
.style.setProperty("--aia-pill-bg", newColor);Selector Hooks
For styling beyond the CSS variables, target these stable data-aia-* attributes:
| Attribute | Element |
|---|---|
| [data-aia-input-wrapper] | Wrapper around the editor area and the submit button — one of the two roots the SDK isolates from page CSS (the dropdown is the other) |
| [data-aia-editor] | Editor area wrapping the contentEditable + inline pill list |
| [data-aia-input] | The contentEditable <div> that owns typed text and bold completed params. Replaces the previous [data-aia-textarea] selector. |
| [data-aia-pill-list-container] | Inline sibling of the editor that holds unfilled-suggestion pills |
| [data-aia-submit] | Submit button |
| [data-aia-pill] | Each unfilled-suggestion pill |
| [data-aia-pillbar] | Pill bar container inside the dropdown |
| [data-aia-pill-scroll] | Scrollable pill region inside the bar — carries the horizontal scroll and right-edge fade mask |
| [data-aia-skip] | The pill bar's trailing "skip" button. Tune via --aia-skip-font-size / --aia-skip-color / --aia-skip-color-hover / --aia-skip-hover-bg |
| [data-aia-option] | Each suggestion option |
| [data-aia-scroll-arrow] | The "more below" arrow on the dropdown — carries data-aia-visible while shown |
| [data-aia-dropdown] | The dropdown root (listbox). Carries data-aia-has-products while the product strip has cards. |
| [data-aia-datepicker] | The calendar, rendered in place of the option list for a date parameter |
| [data-aia-date-month] | The "March 2026" header label |
| [data-aia-date-prev] / [data-aia-date-next] | The month arrows |
| [data-aia-date-grid] | The 7-column grid of day cells |
| [data-aia-date-cell] | Each day cell. Also carries [data-aia-option], so option-level styling applies to both bodies |
| [data-aia-products] | Product strip section (label + row) |
| [data-aia-products-row] | The horizontally scrolling row of cards |
| [data-aia-product] | Each product card |
| [data-aia-product-placeholder] | Media tile of a card whose product has no image |
Completed params render as inline <strong> elements inside the editor. Override their weight with [data-aia-input] strong { font-weight: 700; } — a selector that names the editor wins without !important, while a bare strong { … } page rule is kept out by the isolation reset.
The vanilla core also exposes stable BEM class names (magicx-aia-*); both can be used.
/* Solid (non-glass) dropdown */
#my-autocomplete [data-aia-dropdown] {
background: #fff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
backdrop-filter: none;
}Sessions
Every /api/suggest request carries a meta.session_id UUID. A session runs from instance construction (or the last reset()) until the next reset(). All requests in one session share the same session_id; calling reset() starts a new one.
- Tier 1 (
renderMode: "full") — the SDK auto-resets after both Enter key and built-in submit-button clicks. YouronSubmitruns first, then the SDK clears the input and starts a new session. - Tier 2/3 (
renderMode: "dropdown" | "headless") — you own the input and submit flow, so you must callac.reset()yourself from youronSubmithandler (or from your custom submit button after firingonSubmit).
Why it matters: suggestions get sharper as a query develops. Each one takes account of what the user has already answered, so the parameters offered late in a query are shaped by the choices made early in it — that is what makes the experience feel guided rather than like a static list.
session_idis what ties those requests together into one query.So
reset()is not bookkeeping: it is how you say "that query is finished". Skip it and the next query is treated as a continuation of the last one, and its suggestions keep being shaped by answers the user has already moved on from — the failure is quiet, and shows up as steadily less relevant options rather than as an error.
The id is a plain UUID. Log it next to your own request logs if you want to correlate a user's report with the query that produced it.
// Tier 2/3 — call reset() yourself
const ac = new AIAutocomplete(container, {
renderMode: "dropdown",
apiConfig: { apiKey: "..." },
onSubmit: (result) => {
sendToBackend(result);
ac.reset(); // start the next session
},
});Option Overrides
Supply the options for a parameter yourself instead of taking the server's. Each entry is keyed by the suggestion type and is a function of the phrase the user has typed for that parameter:
new AIAutocomplete(el, {
optionOverrides: {
// A fixed list — a plain array is applied at once, no loading state.
account: () => [
{ text: "Savings", is_tappable: true, kind: null },
{ text: "Checking", is_tappable: true, kind: null },
],
// A list that lives behind a request — return a promise. The dropdown
// shows its loading skeleton until it settles.
location: async (query, signal) => {
const res = await fetch(`/api/locations?q=${encodeURIComponent(query)}`, { signal });
const places = await res.json();
return places.map((p) => ({ text: p.name, is_tappable: true, kind: null }));
},
},
});When it's called. Once the moment the parameter becomes active — a response suggests it, a skip or a selection moves it to the front, or the user taps a completed value of that type to change it — with whatever they have already typed for it (usually "", the request for the default list). Then again on the SDK's typing debounce with each new phrase, so you can run a search or page through a larger list. If a phrase is already covered by what you last returned, return that list again. The third argument is the Suggestion being answered, for the rare override that serves more than one type.
What's shown. Your answer, as-is: it is not filtered again by the phrase it was produced for, so a fuzzy or synonym match survives. Between two calls the previous answer is filtered locally by what the user types, so the list reacts to every keystroke. Typing the full text of an option in the answer completes the parameter, exactly as it does for a server option.
The server's role. The server's own options for an overridden type are never shown. While an override owns the active parameter the server is not asked for suggestions; it is asked again when the parameter is answered or skipped. Return an empty list for a typed phrase and the SDK falls back to the server for that phrase — the typed text goes out the way it does for a parameter with no matching options — and won't ask you the same phrase twice. An empty list for "" leaves the parameter on screen with no options.
Cancellation and errors. Honour signal: it is aborted when a newer phrase supersedes the call, when the parameter stops being active, and on destroy(). A throw or a rejection is logged once per instance and treated as an empty answer, never as a fetch error.
Tier 3 exposes the pieces: state.optionQuery is the phrase, and state.isSearchingOptions is true while an answer is pending.
License
Private package. All rights reserved.
