@magicx-eng/ai-autocomplete-angular
v0.12.0
Published
AI Autocomplete Angular SDK — guided autocomplete with pill-based input and dropdown suggestions
Maintainers
Readme
@magicx-eng/ai-autocomplete-angular
An Angular SDK that provides a guided AI-powered autocomplete experience with pill-based input and dropdown suggestions. Powered by @magicx-eng/ai-autocomplete-vanilla under the hood.
Status: v0.1 — first release. Built for Angular 15+. The published artifact uses ng-packagr's partial-Ivy (APF) output, which is forward-compatible with newer Angular majors. Smoke-tested on Angular 15 consumer apps today; matrix expansion to 16 / 17 / 18 / 19 is planned for v0.2.
Features
- Three tiers of integration — the full
<ai-autocomplete>component,AIAutocompleteController+[aiaInput]+<ai-autocomplete-dropdown>(our dropdown, your input), or the controller alone (render the dropdown yourself) - Rich inline input (Tier 1) — a single
contentEditablesurface: typed text, bold completed params, and inline pills 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
- Pill placement — render pills inline in the input or inside the dropdown
- Light/dark mode — built-in themes with
prefers-color-schemesupport, fully overridable via CSS variables - Inherits your font — defaults to the host page's font, with
--aia-font-familyto pin a specific font on the library - 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
- IME-safe — composition events are buffered so input text is committed once, after composition ends
- 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 as
MONTH DAY YEAR(e.g.March 23 2026). Tapping a committed date re-opens the calendar on the month it holds. - Option overrides — inject or dynamically generate client-side options per suggestion type
- Product strip (opt-in) — plug in any platform's product search and the dropdown renders a horizontal row of product cards below the options; the SDK owns the UI, your integration owns only
fetchandtransform - Controlled & uncontrolled — works out of the box or integrates with external state, including Reactive Forms via
ControlValueAccessor - Imperative handle —
focus(),blur(),reset(), andsetMode()via@ViewChild - Accessible — ARIA combobox 1.2 pattern with
role="listbox",aria-activedescendant - Animations — option selection streak animation, 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
- Lightweight — styles bundled via ng-packagr; no global style import required
- TypeScript first — full type definitions shipped with the package
Installation
pnpm add @magicx-eng/ai-autocomplete-angularPeer Dependencies
pnpm add @angular/common @angular/core @angular/forms rxjsThe package works on Angular 15 and up. RxJS 7 or later is required.
Three Tiers
| Tier | What you get | What you own | Use when |
|---|---|---|---|
| Tier 1: Full | <ai-autocomplete> — input, dropdown, pills, state | Nothing — drop in and go | You want a complete widget with zero setup |
| Tier 2: Controller + dropdown | AIAutocompleteController + [aiaInput] + <ai-autocomplete-dropdown> | The input element and its layout | You need a custom input but want our dropdown UI |
| Tier 3: Headless | AIAutocompleteController alone — observables + actions | Everything, including the dropdown | You need full control over every piece of the UI |
Tier 1: Full Component
Drop-in component that owns the editor, pills, dropdown, and all state:
import { Component } from "@angular/core";
import {
AIAutocompleteComponent,
type AutocompleteResult,
} from "@magicx-eng/ai-autocomplete-angular";
@Component({
standalone: true,
selector: "app-search",
imports: [AIAutocompleteComponent],
template: `
<ai-autocomplete
[apiConfig]="apiConfig"
class="my-autocomplete"
(submitted)="handleSubmit($event)"
></ai-autocomplete>
`,
})
export class SearchComponent {
apiConfig = { endpoint: "https://api.example.com/ac/suggest", apiKey: "your_api_key" };
handleSubmit(result: AutocompleteResult): void {
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", ... }]
}
}Controlled Mode
Use [value] / [completedParams] with their Change outputs, or hook into Reactive Forms.
Two-way binding:
<ai-autocomplete
[(value)]="text"
[(completedParams)]="params"
(submitted)="handleSubmit($event)"
></ai-autocomplete>Reactive Forms (ControlValueAccessor):
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { AIAutocompleteComponent } from "@magicx-eng/ai-autocomplete-angular";
@Component({
standalone: true,
imports: [AIAutocompleteComponent, ReactiveFormsModule],
template: `
<ai-autocomplete [apiConfig]="apiConfig" [formControl]="query"></ai-autocomplete>
`,
})
export class SearchComponent {
query = new FormControl("");
apiConfig = { endpoint: "...", apiKey: "..." };
}Imperative Handle
The component exposes focus(), blur(), reset(), and setMode() as public instance methods — reach them through @ViewChild:
import { Component, ViewChild } from "@angular/core";
import { AIAutocompleteComponent } from "@magicx-eng/ai-autocomplete-angular";
@Component({
standalone: true,
imports: [AIAutocompleteComponent],
template: `
<ai-autocomplete #ac [apiConfig]="apiConfig" (submitted)="onSubmit($event)"></ai-autocomplete>
<button (click)="ac.focus()">Focus</button>
<button (click)="ac.setMode('dark')">Dark mode</button>
`,
})
export class SearchComponent {
@ViewChild("ac") ac!: AIAutocompleteComponent;
// ac.focus() / ac.blur() / ac.reset() / ac.setMode("dark" | "light" | "auto")
}Focus Control
The component auto-focuses the contentEditable editor on mount. To opt out, pass [autoFocus]="false". Listen to focus changes with (focused) / (blurred):
<ai-autocomplete
[autoFocus]="false"
(focused)="onFocus()"
(blurred)="onBlur()"
(submitted)="handleSubmit($event)"
></ai-autocomplete>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. From there:
- Typing atomically replaces the bold with what you type. If what you type exactly matches one of the cached options, it's re-promoted to a bold completed param immediately.
- Clicking an option replaces the bold with the new selection.
- Arrow keys, Escape, or clicking outside the param exit re-edit mode without changing anything.
After a completed param is added (by any means — option click, exact-match typing, or re-edit), the caret always lands right after the trailing space following the bold so typing can continue immediately.
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 dropdown$ so you
can render your own (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 always MONTH DAY YEAR in English — March 23 2026
— regardless of the visitor's locale, so the value you receive has one shape
everywhere. 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 that date's month with the day marked, so changing an answer takes one click.
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-angular";
const due = parseDate("March 23 2026"); // 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.
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: Controller + Dropdown
Drive state with the controller and render our dropdown; you own the input element and layout:
import { Component, OnDestroy, OnInit } from "@angular/core";
import {
AIAutocompleteController,
AIAutocompleteDropdownComponent,
AIAutocompleteInputDirective,
type AutocompleteResult,
} from "@magicx-eng/ai-autocomplete-angular";
@Component({
standalone: true,
selector: "app-search",
imports: [AIAutocompleteDropdownComponent, AIAutocompleteInputDirective],
template: `
<textarea [aiaInput]="ac" placeholder="Ask anything..."></textarea>
<!-- mode styles + themes the dropdown on its own — no .magicx-aia wrapper -->
<ai-autocomplete-dropdown [controller]="ac" mode="auto"></ai-autocomplete-dropdown>
`,
})
export class SearchComponent implements OnInit, OnDestroy {
ac!: AIAutocompleteController;
ngOnInit(): void {
this.ac = new AIAutocompleteController({
apiConfig: { apiKey: "..." },
onSubmit: (result) => {
this.handleSubmit(result);
this.ac.reset(); // start a new session
},
});
}
ngOnDestroy(): void {
this.ac.destroy(); // release resources
}
private handleSubmit(_result: AutocompleteResult) { /* ... */ }
}Always call
reset()after handling submit. It clears the input and rotates the per-sessionsession_id. This applies whether the submit was triggered by Enter, a custom button, or any other mechanism.
Always call
destroy()inngOnDestroy. The controller owns RxJS subscriptions and a vanilla-core instance; failing to dispose leaks them.
The [aiaInput] directive implements ControlValueAccessor, so it works with Reactive Forms out of the box:
<textarea [aiaInput]="ac" [formControl]="myControl"></textarea>
<ai-autocomplete-dropdown [controller]="ac" mode="auto"></ai-autocomplete-dropdown>Color mode & position
Pass [mode] ("light" | "dark" | "auto") to a standalone <ai-autocomplete-dropdown> and it self-scopes the SDK's design tokens to its own root — no .magicx-aia wrapper needed ("auto" follows prefers-color-scheme). To open the dropdown above the input, set optionsPosition: "above" in the controller options — the dropdown reads it from the controller automatically (via dropdown$), and arrow-key direction matches. Leave mode unset inside Tier 1, which themes for you.
Custom / rich-text inputs
[aiaInput] is for a <textarea>/<input>. For a contentEditable or rich-text editor, skip the directive and call the controller directly: handleTextChange(text) on every edit, setFocused(bool) on focus/blur, handleKeyDown(event) for Arrow/Enter/Tab/Escape while the dropdown is open, and handleCaretMove(offset) so arrow keys can move into the dropdown.
Tier 3: Headless
Skip <ai-autocomplete-dropdown> and render the suggestions UI yourself. Subscribe to the controller's dropdown$ (or the individual observables) for the data and call selectOption() / setActiveDropdownIndex() for the actions:
@Component({
standalone: true,
selector: "app-search",
imports: [CommonModule, AIAutocompleteInputDirective],
template: `
<textarea [aiaInput]="ac" placeholder="Ask anything..."></textarea>
<ng-container *ngIf="ac.dropdown$ | async as dd">
<ul class="my-dropdown" role="listbox" *ngIf="dd.isOpen">
<li
*ngFor="let option of dd.suggestions[0]?.options ?? []; let i = index"
role="option"
[attr.aria-selected]="i === dd.activeIndex"
(mouseenter)="ac.setActiveDropdownIndex(i)"
(mousedown)="$event.preventDefault(); ac.selectOption(option)"
>
{{ option.text }}
</li>
</ul>
</ng-container>
`,
})
export class SearchComponent implements OnInit, OnDestroy {
ac!: AIAutocompleteController;
ngOnInit() { this.ac = new AIAutocompleteController({ apiConfig: { apiKey: "..." } }); }
ngOnDestroy() { this.ac.destroy(); }
}Datepicker in a custom UI. For a date parameter,
dd.formatTypeis"date"anddd.suggestions[0].optionsholds that month's day cells. Each cell'stextis the date it commits ("March 23 2026"), so the list above already works —selectOption(cell)behaves exactly as it does for an option. To draw an actual calendar, readdd.dateViewfor the month on show,cellDay(cell)for the number to paint, and callac.showPreviousMonth()/ac.showNextMonth()to page. Cells that pad the start and end of the month haveis_tappable: false.
Convenience NgModule
For codebases that prefer the older NgModule pattern, an AIAutocompleteModule re-exports every public component and directive:
import { NgModule } from "@angular/core";
import { AIAutocompleteModule } from "@magicx-eng/ai-autocomplete-angular";
@NgModule({ imports: [AIAutocompleteModule], /* ... */ })
export class AppModule {}Standalone-first apps don't need this — import the components/directive directly.
API Reference
<ai-autocomplete>
| Input | Type | Default | Description |
|---|---|---|---|
| apiConfig | APIConfig | — | Runtime API configuration (see below). |
| additionalContext | Record<string, unknown> | — | Optional user context. Include whatever you know about the user (a profile, preferences, workspace, anything) to personalize suggested parameters and options to them. |
| optionOverrides | Record<string, (query: string) => SuggestionOption[]> | — | Override options per suggestion type. |
| maskCompletedText | boolean | false | When true, omits completed params' literal text from API requests (for masking PII/sensitive values from the server). |
| columns | number | 2 | Number of columns in the dropdown grid. |
| pillPlacement | "inline" \| "dropdown" \| "hidden" | "dropdown" | Where to render unfilled pills. "hidden" hides pills entirely. |
| mode | "light" \| "dark" \| "auto" | "auto" | Color mode. "auto" follows prefers-color-scheme. |
| optionsPosition | "above" \| "below" | "below" | Where the dropdown opens relative to the input. |
| animations | boolean | true | Enable/disable all SDK animations (streak + shimmer). |
| dropdownTrigger | "auto" \| "manual" \| "hidden" | "auto" | When the dropdown appears. "auto" = when options available. "manual" = only on pill tap, closes after selection. "hidden" = never shows. |
| closeDropdownOnBlur | boolean | true | When true, the dropdown closes if the input loses focus. Set to false to keep it open whenever options are available, regardless of focus. |
| showNonTappableOptions | boolean | true | When true, non-tappable options are rendered alongside tappable ones in the dropdown. Set to false to hide non-tappable options entirely. |
| showSkipButton | boolean | true | When true, the dropdown's pill bar ends in a small "skip" button that dismisses the active pill — same action as pressing → at the end of the input. It sits top-right when the dropdown opens below the input, bottom-right when optionsPosition is "above", and appears once the input has text (alongside the footer's "→ to skip" hint). Set to false to hide it. |
| autoFocus | boolean | true | Focus the input on mount. Set to false to leave focus to the consumer. |
| value | string | — | Controlled text value. Pair with (valueChange) or use [(value)]. |
| completedParams | CompletedParamState[] | — | Controlled completed params. Pair with (completedParamsChange) or use [(completedParams)]. |
| products | ProductsConfig | — | Opt-in product strip — see Product strip. Bind a stable reference (a component field, not an inline object literal). Omit it and nothing about the dropdown changes. |
| submitButton | TemplateRef<unknown> \| null | — | Custom submit button. Pass a <ng-template> to replace the default arrow button. Pass null to render no button. Clicks bubble up and trigger submit, so consumer-supplied buttons work without re-wiring. |
| Output | Payload | Description |
|---|---|---|
| submitted | AutocompleteResult | Fires on Enter or submit-button click. |
| errored | Error | Fires when a fetch fails. |
| valueChange | string | Two-way binding partner for [value]. |
| completedParamsChange | CompletedParamState[] | Two-way binding partner for [completedParams]. |
| focused | void | The input gained focus. |
| blurred | void | The input lost focus. |
| productSelect | Product | A product card was activated. The SDK never navigates. |
Imperative methods (via @ViewChild):
| Method | Description |
|---|---|
| focus() | Focus the editor. |
| blur() | Blur the editor. |
| reset() | Clear all state, re-fetch, and start a new session (rotates session_id). |
| setMode(mode) | Switch color mode at runtime. |
Product strip
Opt in with [products] and the dropdown renders a horizontal row of product
cards below the options grid. Every platform (Shopify today, others later) has
its own search endpoint and its own response shape, so the SDK owns the UI and
the integration owns only the fetching and the mapping.
import { Component } from "@angular/core";
import type { Product, ProductsConfig } from "@magicx-eng/ai-autocomplete-angular";
@Component({
template: `<ai-autocomplete
[apiConfig]="apiConfig"
[products]="products"
(productSelect)="openProduct($event)"
(submitted)="onSubmit($event)"
></ai-autocomplete>`,
})
export class SearchComponent {
// A component field, not an inline object literal: a new reference on each
// change-detection pass reads as a swapped integration and clears the
// current cards (the same rule `apiConfig` and `optionOverrides` follow).
products: ProductsConfig = {
// You own the request entirely — auth headers, GraphQL body, locale
// prefixes. Honour the signal: the SDK aborts it as soon as a newer query
// supersedes this one.
fetch: (query, signal) =>
fetch(`/api/products?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json()),
// Pure mapping, kept out of `fetch` so you can unit-test it without a
// network.
transform: (raw): Product[] =>
(raw as ApiResponse).items.map((item) => ({
id: item.id,
title: item.title,
url: item.url,
imageUrl: item.image?.src ?? null, // null renders the placeholder tile
price: formatMoney(item.price), // you know the currency
vendor: item.brand,
})),
limit: 8, // applied by the SDK, after transform
};
// Selection emits — the SDK never navigates.
openProduct(product: Product): void {
this.router.navigateByUrl(product.url);
}
}Product is exactly six fields; only id, title and url are required:
type Product = {
id: string;
title: string;
url: string;
imageUrl?: string | null;
price?: string;
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. - Empty queries never reach you. On mount and after
reset()the strip 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, noerroredoutput, 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 option list.
dropdownTrigger(manual/hidden) still gates the panel as before.
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 (productSelect) instead.
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.
Swapping [products] for a different config (or undefined) clears the current
cards; the strip repopulates on the next /suggest request rather than
immediately, since the product search rides that one scheduler.
Tier 2 gets the same thing: pass products / onProductSelect to the
AIAutocompleteController and <ai-autocomplete-dropdown> renders the strip.
Tier 3 consumers subscribe to products$ and call
controller.selectProduct(product) from their own cards.
Custom submit button
<ng-template #goBtn>
<button class="my-button">Go</button>
</ng-template>
<ai-autocomplete
[submitButton]="goBtn"
(submitted)="handleSubmit($event)"
></ai-autocomplete>
<!-- Or hide the button entirely: -->
<ai-autocomplete [submitButton]="null" (submitted)="handleSubmit($event)"></ai-autocomplete>APIConfig
A discriminated union: APIKeyConfig | AccessTokenConfig.
API Key Mode (default)
{ apiKey: "your_api_key", authScheme: "Bearer", endpoint: "/ac/suggest" }| Field | Type | Description |
|---|---|---|
| type? | "apiKey" | Optional discriminator. Default when omitted. |
| apiKey? | string | API key for Authorization header. |
| authScheme? | "Bearer" \| "Basic" | Auth header scheme. Default: "Bearer". |
| endpoint? | string | Full URL for the suggest endpoint. Default: "https://api.ai-autocomplete.com/api/suggest". |
| appIdentifier? | string | Value for the X-App-Identifier header. |
| headers? | Record<string, string> | Additional headers merged into every request. |
Access Token Mode
<ai-autocomplete
[apiConfig]="apiConfig"
(submitted)="handleSubmit($event)"
></ai-autocomplete>apiConfig: AccessTokenConfig = {
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 };
},
};| Field | Type | Description |
|---|---|---|
| type | "accessToken" | Required discriminator. |
| getAccessToken | () => Promise<AccessTokenResult> | Required. Called when the SDK needs a token. |
| accessToken? | string | Initial token. Avoids one round-trip on mount. |
| endpoint? | string | Suggest endpoint URL. Default: "https://api.ai-autocomplete.com/api/suggest". |
| appIdentifier? | string | Value for the X-App-Identifier header. |
| headers? | Record<string, string> | Additional headers merged into every request. |
The SDK handles token refresh transparently: 401 → getAccessToken → retry (once). Concurrent 401s share a single refresh. Tokens refresh proactively 30s before expiresAt.
AIAutocompleteController (Tier 2)
The headless logic surface for Tier 2 — the Angular equivalent of React's useAIAutocomplete() hook.
const ac = new AIAutocompleteController({
apiConfig: { ... },
onSubmit: (result) => { ... },
});
// ... wire up [aiaInput] and <ai-autocomplete-dropdown> in your template ...
// On teardown:
ac.destroy();Constructor options
Accepts the same shape as <ai-autocomplete>'s inputs (apiConfig, additionalContext, optionOverrides, columns, dropdownTrigger, optionsPosition, closeDropdownOnBlur, showNonTappableOptions, products), plus event callbacks (onSubmit, onError, onChange, onParamsChange, onFocus, onBlur, onProductSelect) and controlled-mode initial values (value, completedParams).
State observables
| Observable | Type | Description |
|---|---|---|
| text$ | Observable<string> | Editor's plain-text content. |
| completedParams$ | Observable<CompletedParamState[]> | Bold completed params currently in the editor. |
| skippedParams$ | Observable<SkippedParamState[]> | Suggestions the user dismissed with →. Nothing renders them — pass them to buildSubmitResult for a hand-rolled submit. |
| suggestionPills$ | Observable<Suggestion[]> | Actionable suggestion pills (first item is the active pill). |
| segments$ | Observable<Segment[]> | Text + completed-param segments for rendering the editor body. |
| newParamId$ | Observable<string \| null> | ID of the most-recently-promoted param (drives shimmer animation). |
| suggestions$ | Observable<Suggestion[]> | All suggestions from the server (including non-actionable ones). |
| products$ | Observable<Product[]> | Results of the latest product search. Always empty unless products is configured — see Product strip. |
| activeIndex$ | Observable<number> | Currently highlighted dropdown option index. -1 = none. |
| isLoading$ | Observable<boolean> | UI-visible loading flag (suppressed during selection animation and re-edit). |
| isReady$ | Observable<boolean> | Server-reported "query is complete" flag. |
| isFocused$ | Observable<boolean> | Whether the editor currently has focus. |
| isDropdownOpen$ | Observable<boolean> | Whether the dropdown should currently be visible. |
| placeholderText$ | Observable<string> | Server-suggested placeholder text for the editor. |
| error$ | Observable<Error \| null> | Last fetch error, or null. |
| editingParam$ | Observable<CompletedParamState \| null> | Param currently being re-edited, or null. |
| editingAnchor$ | Observable<number \| null> | Plain-text offset where the edit region starts. |
| caretOffset$ | Observable<number \| null> | Live caret offset within the editor. |
| state$ | Observable<AIAutocompleteControllerState> | Composite snapshot of every public field. |
| dropdown$ | Observable<AIAutocompleteDropdownState> | Composite snapshot for the dropdown (suggestions, activeIndex, isOpen, pills, optionsPosition, formatType, dateView, …). Bound to <ai-autocomplete-dropdown>, or subscribe directly to render your own dropdown in Tier 3. For a date parameter, formatType is "date" and suggestions[0].options holds that month's day cells — each cell's text is the date it commits, and cells padding the start/end of the month have is_tappable: false. See Datepicker. |
Every observable is gated by distinctUntilChanged + shareReplay({ bufferSize: 1, refCount: true }), so multiple async-pipe subscriptions share one upstream subscription and dedupe identical emissions.
Action methods
| Method | Description |
|---|---|
| reset() | Clear all state, rotate session_id, and re-fetch. Call after submit. |
| selectOption(option) | Pick a dropdown option (or replace the re-edited param if in re-edit mode). |
| selectProduct(product) | Announce a product selection (fires onProductSelect). <ai-autocomplete-dropdown> calls this for you; hand-rolled strips call it themselves. Never navigates. |
| setActivePill(index) | Promote the pill at index to active. |
| showPreviousMonth() / showNextMonth() | Page the calendar a month at a time; paging never commits anything. <ai-autocomplete-dropdown> wires its own month arrows to these, so you only need them for a hand-rolled calendar. No-ops unless the active parameter is a date. |
| 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"). Also exposed as a Tier 1 component method. |
| removeLastParam() | Drop the last completed param's "completed" status. |
| clearNewParamId() | Clear the shimmer-animation state. |
| setValue(text) | Controlled-mode setter for editor text. |
| setCompletedParams(params) | Controlled-mode setter for completed params. |
| handleTextChange(value) | Forward plain-text input from your <textarea> (the [aiaInput] directive does this for you). |
| handleKeyDown(event) | Forward keyboard events (also handled by [aiaInput]). |
| setFocused(focused) | Notify the controller of focus state (custom inputs only — [aiaInput] does this). |
| handleCaretMove(offset) | Report the caret position (plain-text offset) so arrow keys can move into the dropdown (custom inputs only). |
| setActiveDropdownIndex(index) | Set the highlighted option index (typically driven by hover). |
| update(opts) | Apply runtime changes to a subset of options (everything except event callbacks). |
| destroy() | Release all resources. Must be called in ngOnDestroy. |
Getters
| Field | Type | Description |
|---|---|---|
| listboxId | string | Stable ARIA listbox ID. |
| destroyed | boolean | Whether destroy() has been called. |
| getState() | AIAutocompleteControllerState | Synchronous snapshot of the current state. |
[aiaInput] directive
Wires a <textarea> (or <input>) to an AIAutocompleteController:
<textarea [aiaInput]="controller" placeholder="..."></textarea>The directive automatically:
- Sets
role="combobox",aria-autocomplete="list",aria-controls,aria-expanded,aria-activedescendantfor the WAI-ARIA combobox 1.2 pattern. - Forwards
input/keydown/focus/blurto the controller. - Auto-capitalizes the first character of the user's first keystroke (matching the SDK's UX). Skipped during IME composition.
- Mirrors the controller's
text$back into the textarea (so programmaticcontroller.setValue(...)updates the DOM), preserving the cursor position when possible. - Implements
ControlValueAccessor— use[formControl]or[(ngModel)]directly.
<ai-autocomplete-dropdown>
The dropdown component for Tier 2:
<ai-autocomplete-dropdown [controller]="ac" mode="auto"></ai-autocomplete-dropdown>| Input | Type | Default | Description |
|---|---|---|---|
| controller | AIAutocompleteController | — | The shared controller. |
| showPills | boolean | true | Whether to render pills above the options grid. |
| mode | "light" \| "dark" \| "auto" | — | Color mode for a standalone dropdown — self-scopes the SDK tokens so no .magicx-aia wrapper is needed. "auto" follows prefers-color-scheme. Leave unset when nested inside a .magicx-aia ancestor (e.g. Tier 1). |
| className | string | — | Extra CSS class applied to the dropdown root. |
Placement (optionsPosition) is read from the controller's options via dropdown$ — set it once on the controller, not on the dropdown. The product strip comes from there too: configure products on the controller and the dropdown renders the cards. All actions (option selection, hover, pill clicks, product activation) are forwarded to the controller — no outputs to wire.
AutocompleteResult
| Field | Type | Description |
|---|---|---|
| query | string | Plain text as the user sees it. |
| raw_query | string | Text with placeholder tokens (e.g. "Create a {{TASK_1}}"). |
| completed_params | CompletedParam[] | Filled parameter values, followed by any the user skipped (see below). |
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 }Tier 2 consumers who build their own submit payload get the raw skips from the controller (skippedParams$, or getState().skippedParams) and can fold them in the same way with the exported buildSubmitResult(text, completedParams, skippedParams).
Reading
skippedParams$directly: 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.buildSubmitResultapplies the rule for you; to apply it elsewhere (say, a "you skipped X" badge), use the exportedwithSkippedParams(completedParams, skippedParams).
CSS Customization
Styles are bundled with the package; the published library wires its CSS up automatically. Built-in light and dark defaults apply based on [mode].
CSS Variables
Override on the host element (via class). All defaults use :where() (zero specificity) — your overrides always win.
| Variable | Light | 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 | Highlighted option background |
| --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 |
| --aia-submit-color | #ffffff | #000000 | Submit button icon color |
| --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 used to tint the option-selection streak animation. |
| --aia-streak-glass-bg | rgba(99, 102, 241, 0.1) | rgba(255, 255, 255, 0.1) | Background fill for the streak's glass-pill effect. |
| --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-product-placeholder-color | --aia-option-color | --aia-option-color | Glyph color of the no-image placeholder tile. |
| --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-cell-size | 36px | 36px | Size of a day's square — the box that carries the highlight, the today ring and the selected fill. |
| --aia-date-row-height | 40px | 40px | Height of one week row. Lower it to fit a 6-week month in a shorter dropdown. |
| --aia-date-cell-font-size | 14px | 14px | 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-skeleton-bg | rgba(189, 189, 189, 0.25) | #1a1b1d | Fill color for the loading skeleton bars and masked text in cached pills/options. |
| --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. |
Per-mode Overrides
.my-autocomplete[data-mode="light"] {
--aia-pill-bg: #e2e8f0;
}
.my-autocomplete[data-mode="dark"] {
--aia-pill-bg: #334155;
}Selector Hooks
For styling beyond the CSS variables, target these stable data-aia-* attributes (component-internal class names are not part of the public API):
| Attribute | Element |
|---|---|
| [data-aia-editor] | Editor area wrapping the contentEditable + inline pill list |
| [data-aia-input] | The Tier 1 contentEditable <div> that owns typed text and bold completed params |
| [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-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; }.
/* 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 mount (or the last reset()) until the next reset(). All requests in one session share the same session_id; calling reset() starts a new one.
The contract is simple: after the user submits the query, call reset(). That clears the input and rotates session_id so the next session begins clean.
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.
- Tier 1
<ai-autocomplete>does this automatically — it callsreset()for you aftersubmittedfires, for both Enter-key and built-in-button submits. - Tier 2
AIAutocompleteController— you own the submit flow, so callcontroller.reset()from youronSubmithandler (see the Tier 2 example above) or from your custom button after firing your submit handler.
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.
Option Overrides
@Component({
template: `
<ai-autocomplete
[apiConfig]="apiConfig"
[optionOverrides]="overrides"
(submitted)="handleSubmit($event)"
></ai-autocomplete>
`,
})
export class SearchComponent {
overrides = {
account: () => [
{ text: "Savings", is_tappable: true, kind: null },
{ text: "Checking", is_tappable: true, kind: null },
],
value: (query: string) => {
const digits = query.replace(/\D/g, "");
if (!digits) return [{ text: "$100", is_tappable: true, kind: null }];
return [{ text: `$${digits}`, is_tappable: true, kind: null }];
},
};
}Server-side rendering
The package is render-blocking-safe — the Tier 1 component renders a static shell during SSR / Angular Universal, and the live autocomplete only mounts on the client. For Tier 2 (new AIAutocompleteController(...)), gate construction with isPlatformBrowser:
import { Inject, PLATFORM_ID } from "@angular/core";
import { isPlatformBrowser } from "@angular/common";
constructor(@Inject(PLATFORM_ID) private platformId: object) {
if (isPlatformBrowser(platformId)) {
this.ac = new AIAutocompleteController({ ... });
}
}License
MIT.
