npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@magicx-eng/ai-autocomplete-angular

v0.41.0

Published

AI Autocomplete Angular SDK — guided autocomplete with pill-based input and dropdown suggestions

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 contentEditable surface: 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-scheme support, fully overridable via CSS variables
  • Inherits your font — defaults to the host page's font, with --aia-font-family to 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 the way it would be written — Tuesday for a date inside the next week, March 23 for one later this year, March 23 2027 for 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 date-range parameter 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 /suggest response reports as matching the query; plug in your own product search instead with [products], or turn the strip off with [showProducts]="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, including Reactive Forms via ControlValueAccessor
  • Imperative handle — focus(), blur(), reset(), and setMode() via @ViewChild
  • Accessible — ARIA combobox 1.2 pattern with role="listbox", aria-activedescendant
  • 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
  • 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-angular

Peer Dependencies

pnpm add @angular/common @angular/core @angular/forms rxjs

The 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}}", parameter: "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. Which parameters are dates is the server's call: one whose type is date gets a calendar, and one without a date type keeps its option list, however date-like its name or options look.

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 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-angular";

// 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 ours

parseLooseDate 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 type is date_range 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.

It commits as one parameter, written as a span — 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: 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-session session_id. This applies whether the submit was triggered by Enter, a custom button, or any other mechanism.

Always call destroy() in ngOnDestroy. 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.formatType is "date" and dd.suggestions[0].options holds that month's day cells. Each cell's text is the date it commits ("March 23", or "Tuesday" inside the next week), so the list above already works — selectOption(cell) behaves exactly as it does for an option. To draw an actual calendar, read dd.dateView for the month on show, cellDay(cell) for the number to paint, and call ac.showPreviousMonth() / ac.showNextMonth() to page. Cells that pad the start and end of the month have is_tappable: false. For a range parameter dd.formatType is "date-range" and the same selectOption(cell) is called twice: the first records the start (dd.dateRangeStart holds it, and nothing is committed yet), the second commits the span. dateCellMarks and visibleDateRange are exported to paint the two ends and the band between them the way <ai-autocomplete-dropdown> does.

Option icons. An option may carry an icon: icon_svg is inline SVG markup and icon its 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, pass icon_svg through the exported sanitizeOptionIconSvg before inserting it — it reduces the markup to plain vector drawing and returns null for anything else — and use optionLabel(option) for the text — it is the option's text alone; icon is the icon's name and is never shown as text, even when icon_svg is missing or fails to draw. A picked option's icon / icon_svg are copied onto its completed parameter, and the chip in the input draws the glyph before its text. Both surfaces can be switched off: showOptionIcons: false renders option rows as text only and showChipIcons: false renders chips as text only; either way the icon element is omitted rather than hidden.

Option pictures and counts. An option may also carry image_url — a picture of the best-matching catalog item offering it — and item_count, how many items offer it (each is present only when the server includes it; when it includes counts, the options also arrive ordered by them, most items first; a starting state carries pictures but no counts). The built-in dropdown draws the picture as a square thumbnail before the option's text (in place of its icon_svg, so a row never leads with two visuals) and the count as a muted label at the row's trailing edge ("12 items"); a skeleton row keeps the tile's placeholder fill and hides the count. Icons and pictures are all-or-nothing per list: if any tappable option would draw neither, no row draws one, so a list never mixes rows with and without a leading visual (non-tappable rows don't count). A custom dropdown can apply the same rule with the exported optionsShowLeadingVisuals(options, showIcons, showImages). The unit word after the number is a token: set --aia-option-count-unit: "sneakers" to name what is being counted, --aia-option-count-unit-one: "sneaker" for a count of 1 (it falls back to the plural token), or --aia-option-count-unit: "" for the number alone. When rendering options yourself, pass image_url through the exported optionImageSrc before using it as an <img src> — it returns the URL only when it is http(s) and null otherwise — and formatOptionCount(option.item_count) gives the number the built-in rows print (null when there is no count); optionCountLabel(count, unit?) appends a unit for a dropdown that prints the word itself ("item" / "items" by default; one word, a { one, other } pair, or "" for none). Both surfaces can be switched off: showOptionImages: false renders rows without pictures (an option's icon then draws instead) and showOptionCounts: false hides the counts. A picked option's image_url is copied onto its completed parameter too, and the chip in the input draws it as a small rounded tile before its text, in place of the icon; showChipImages: false renders chips without pictures (the icon then draws instead). The tile is sized by --aia-option-image-size (40px), rounded by --aia-option-image-radius (12px), spaced by --aia-option-image-gap (12px) and filled by --aia-option-image-bg (default: --aia-skeleton-bg) as a placeholder until its picture loads (and on skeleton rows, or if the picture fails); a loaded picture is scaled to fit rather than cropped and sits on the dropdown's background, and one narrower than the tile is rounded on its own corners by --aia-option-picture-radius (6px); in a grid with pictures, an icon row draws its glyph at --aia-option-tile-icon-size (1.5em) so it holds the picture column; the count reads --aia-option-count-font-size (12px), --aia-option-count-color and --aia-option-count-opacity (0.6); the chip's tile reads --aia-chip-image-size (1.25em of the chip's font), --aia-chip-image-radius (4px) and --aia-chip-image-bg (default: --aia-skeleton-bg), the placeholder fill shown until the picture loads; a loaded picture narrower than the tile narrows it to the picture's own width, so the radius rounds the picture.

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). | | visitorId | string \| (() => string \| undefined) | — | Stable UUID v4 for this visitor, kept across page loads, or a function returning it. See Visitor id and submits. | | 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. | | language | string | browser language | BCP 47 language tag of the shopper (e.g. "es"), sent with every suggestion request. Default: the browser language (navigator.language), else "en-US". | | productId | string | — | Your product id. When set, what the dropdown offers before anything is typed is read from a cacheable URL for your product, with no credential, instead of a suggestion request; it falls back to a suggestion request when it cannot be read. | | optionOverrides | OptionOverrides | — | Supply the options for a parameter yourself, per suggestion type — fixed, computed, or fetched as the user types. See Option Overrides. | | 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 (press + shimmer, the typed-in starting-state placeholder, the option rows' staggered entrance, and the "more below" arrow's slide). prefers-reduced-motion also switches the last three off. | | 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. | | showOptionIcons | boolean | true | When true, an option that carries an icon_svg draws its icon before its text in the dropdown. Set to false to render option rows as text only. | | showChipIcons | boolean | true | When true, a completed chip in the input draws the icon of the option that answered it. Set to false to render chips as text only. | | showChipImages | boolean | true | When true, a completed chip in the input draws the picture (image_url) of the option that answered it, in place of its icon. Set to false to render chips without pictures. | | showOptionImages | boolean | true | When true, an option that carries an image_url draws it as a square thumbnail before its text in the dropdown, in place of its icon. Set to false to render option rows without pictures. | | showOptionCounts | boolean | true | When true, an option that carries an item_count shows it as a muted label at the row's trailing edge ("12 items"). Set to false to hide the counts. | | 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)]. | | showProducts | boolean | true | Render the product strip below the options whenever there are products to show — see Product strip. Set to false to render no strip. | | productsLayout | "row" \| "list" | "row" | How the strip lays its cards out: a horizontally scrolling row, or the same cards stacked as full-width rows — see Product strip. | | productImageFallback | "image" \| "initial" | "image" | Fallback for a missing or blank product image URL, or an image load error. "image" shows the placeholder pictogram; "initial" shows the title initial. A working image still takes priority. See Product strip. | | products | ProductsConfig | — | Custom source for the product strip — see Your own product search. Bind a stable reference (a component field, not an inline object literal). Omit it and the strip shows the items the /suggest response reports. | | resetOnSubmit | boolean | true | Clear the editor and start a new session after (submitted) emits. Set to false to keep the query in place — e.g. when the handler navigates away — and call reset() yourself. | | 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. | | resultChange | AutocompleteResult | Fires after every successful round-trip with the structured query as it now stands — see Reading the query as it's built. | | 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

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 emission as the suggestions, so what the strip shows and what the pills offer always describe the same narrowing.

<ai-autocomplete
  [apiConfig]="apiConfig"
  (productSelect)="openProduct($event)"
  (submitted)="onSubmit($event)"
></ai-autocomplete>

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 — opened on the variant the query chose (?variant=…, e.g. the blue one for "blue shoes") when there is one, whose price and image the card then shows. available is carried over when the server reports it. An item with neither gets no url: its card is still activatable and still emits (productSelect), it just has nothing for cmd-click to open. productFromMatchedItem / productsFromCustomFields are exported for a hand-rolled strip that wants the same mapping.

Bind [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. Changing the binding re-lays the strip out at once, and <ai-autocomplete-dropdown> takes the same input in Tier 2.

Product image fallback. A product's image takes priority when its imageUrl loads successfully. A missing or blank URL, or an image load error, uses productImageFallback: "image" (the default) shows the existing placeholder pictogram; "initial" shows the first character of the trimmed, uppercased title. This is a Unicode grapheme (so a combined character or emoji stays together), with a Unicode code point fallback in older browsers. An empty or whitespace-only title shows ?. The fallback works in both row and list layouts. Initial tiles are decorative (aria-hidden="true"); the card's visible title supplies its text.

Sold out. A product with available: false shows a "Sold out" badge over the top-left of its image (in the list layout, a smaller banner across the bottom of the thumbnail) and its image is desaturated; the card stays selectable and keeps its link. Absent or true shows no badge, so leave available out when you don't know. The badge is part of the card's text, so screen readers announce it. Theme it with --aia-product-badge-bg, --aia-product-badge-color, --aia-product-badge-font-size and --aia-product-badge-inset, and the list banner with --aia-product-badge-list-bg, --aia-product-badge-list-color and --aia-product-badge-list-font-size.

Bind productImageFallback on <ai-autocomplete> in Tier 1 or directly on <ai-autocomplete-dropdown> in Tier 2:

<!-- Tier 1 -->
<ai-autocomplete
  [apiConfig]="apiConfig"
  [productImageFallback]="'initial'"
  (submitted)="onSubmit($event)"
></ai-autocomplete>

<!-- Tier 2 -->
<ai-autocomplete-dropdown
  [controller]="ac"
  [productImageFallback]="'initial'"
  mode="auto"
></ai-autocomplete-dropdown>

Style the initial tile itself with [data-aia-product-initial]. For example, wrap the widget in an element with id="catalog-search" and add this rule to your stylesheet (the global stylesheet in Angular). The existing --aia-product-media-bg variable sets the background; color, font-size and border-radius style the initial without additional SDK options.

#catalog-search [data-aia-product-initial] {
  --aia-product-media-bg: #eef2ff;
  color: #4338ca;
  font-size: 22px;
  border-radius: 12px;
}

The catalog report (customFields$)

Every response that read the catalog also says what it did with the query. The controller exposes it as customFields$ (and on state$), and it rides on AutocompleteResult as custom_fields, so (resultChange) / onResult carry it too:

<ai-autocomplete (resultChange)="report = $event.custom_fields ?? null"></ai-autocomplete>

<p *ngIf="report">{{ report.items.total }} results</p>
<!-- e.g. { parameter: "Color", value: "Chartreuse", op: "eq" } — the shopper asked
     for a colour no item carries, so the results ignore it. Say so. -->
<p *ngIf="report?.dropped_filters?.length">
  No match for
  <ng-container *ngFor="let filter of report.dropped_filters; let last = last">
    {{ filter.value }}<ng-container *ngIf="!last">, </ng-container>
  </ng-container>
</p>
  • applied_filters / dropped_filters — the constraints the server read out of the query as { parameter, value, op } (op is one of eq, 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.matched is the list the strip renders, capped by the server; it is empty (with a non-zero total) when nothing narrowed the catalog, where listing arbitrary items as matches would mislead.
  • null (no custom_fields key on the result) means 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.

Change product cards before display

  • mapProducts(products, items) synchronously returns the cards to show.

  • The SDK calls it once per response with its default cards and their matched items in the same order.

  • The returned array becomes the product state and the displayed strip.

  • A thrown error or a non-array result keeps the default cards. A thrown error warns once per instance.

  • refreshProducts() maps the current items again without a request. Call it when your own data arrives.

  • Both APIs leave a custom products integration unchanged.

  • Pass mapProducts to new AIAutocompleteController({ mapProducts }) and call controller.refreshProducts().

  • Bind [mapProducts]="mapProducts" on <ai-autocomplete> and call refreshProducts() on the component.

  • Changing the callback through controller.update({ mapProducts }) or the input maps the current items again.

  • Define the callback as a stable component field.

mapProducts = (products: Product[], items: MatchedItem[]): Product[] =>
  products.map((product, index) => ({
    ...product,
    price: this.shopperPrices[items[index].source_id] ?? "",
  }));

Your own product search (products)

To source the cards from your own search instead — a platform with its own endpoint and response shape — bind [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 { 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 uses the configured productImageFallback
        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 {
    if (product.url) this.router.navigateByUrl(product.url);
  }
}

Product is exactly seven 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;
  price?: string;
  vendor?: string;
  available?: boolean;        // false shows a "Sold out" badge
};

What the SDK guarantees

  • One fetch cadence. The product search rides the same debounce, the same AbortController and 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 fetch or a throwing transform clears the strip, logs once, and leaves the suggestions half untouched — no error state, no errored output, no effect on isLoading$.
  • 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 clears the current cards; the strip repopulates on the next /suggest request rather than immediately, since the product search rides that one scheduler. Binding it back to undefined hands the strip back to the server's items at once, and toggling [showProducts] empties or restores it at once.

Tier 2 gets the same thing: pass showProducts / products / onProductSelect to the AIAutocompleteController and <ai-autocomplete-dropdown> renders the strip. Tier 3 consumers subscribe to products$ (and customFields$) 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();
    // expires_at is Unix seconds; the SDK's expiresAt is Unix milliseconds.
    return { accessToken: access_token, expiresAt: expires_at * 1000 };
  },
};

| 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 (Unix milliseconds).

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, language, productId, visitorId, optionOverrides, columns, dropdownTrigger, optionsPosition, closeDropdownOnBlur, showNonTappableOptions, showProducts, products), plus event callbacks (onSubmit, onResult, onError, onChange, onParamsChange, onFocus, onBlur, onProductSelect) and controlled-mode initial values (value, completedParams). onResult fires after every successful round-trip — see Reading the query as it's built.

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. | | identifiedParams$ | Observable<IdentifiedParamState[]> | Parameters the server recognised in the user's own words. Already rendered through segments$; pass them to buildSubmitResult for a hand-rolled submit so it reports the same identified_params the SDK's own results do. | | 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[]> | 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. | | customFields$ | Observable<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. | | 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" (or "date-range" for a span, answered in two clicks) 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. | | recordSubmit(result) | Record a submit from a button you render. Enter is recorded for you; call this once per button 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. | | getResult() | AutocompleteResult | The structured query as it stands right now — the same object onResult delivers after each response. |

[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-activedescendant for the WAI-ARIA combobox 1.2 pattern.
  • Forwards input / keydown / focus / blur to 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 programmatic controller.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). | | productImageFallback | "image" \| "initial" | "image" | Fallback for a missing or blank product image URL, or an image load error. "image" shows the placeholder pictogram; "initial" shows the title initial. A working image still takes priority. See Product strip. Set this on the dropdown itself in Tier 2. | | 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: the dropdown renders the server's items by default, or the cards of a products config set on the controller. All actions (option selection, hover, pill clicks, product activation) are forwarded to the controller — no outputs to wire.

AutocompleteResult

The structured query as the SDK currently understands it. The same object is emitted by (submitted) / onSubmit when the user submits, by (resultChange) / onResult after every successful round-trip, and returned by controller.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 { parameter, text } — e.g. { parameter: "due_date", text: "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 { parameter, 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. (resultChange) on the Tier 1 component — onResult on the Tier 2 controller — 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.

<ai-autocomplete
  (resultChange)="preview = $event"
  (submitted)="run($event)"
></ai-autocomplete>
// Tier 2
const ac = new AIAutocompleteController({
  onResult: (result) => this.saveDraft(result),
  onSubmit: (result) => this.run(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 init (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 ((errored) / onError does), 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 it lags the input by a beat; 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: "", parameter: "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, { identifiedParams, isReady }) — the last argument carries identifiedParams$ / isReady$ (or their getState() fields) so a hand-rolled submit reports the same identified_params and is_ready the SDK's own results do.

Reading skippedParams$ directly: the array is append-only until reset(). The "drop a skip whose parameter got filled" rule is applied when the payload is built, not by pruning the array — so if the user skips goal and later fills one, the raw array still holds the goal entry. That's deliberate: the filter self-heals if they then delete that param's text, where pruning would discard the signal for good. buildSubmitResult applies the rule for you; to apply it elsewhere (say, a "you skipped X" badge), use the exported withSkippedParams(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].

Every class name the package emits is namespaced — magicx-aia-* for the classes it shares with the vanilla core, aia-* for its own — 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. Use the --aia-* variables and data-aia-* selector hooks below for overrides.

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 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.

CSS Variables

Override on the host element (via class). The variables are declared with :where() (zero specificity), so a value you set on the host always wins.

The full input also accepts --aia-input-radius and --aia-written-text-font-weight on its container or an ancestor. Omit them to retain the default 10px radius and 300 weight; changing mode does not reset these overrides. In dropdown-only integrations, style your own input directly.

.my-search {
  --aia-input-radius: 0;
  --aia-written-text-font-weight: 500;
}

| 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-input-radius | 10px | 10px | Full input corner radius. Set 0 for square corners. | | --aia-written-text-font-weight | 300 | 300 | Written text weight in the full input. Completed chips keep their own typography. | | --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) | No longer used: the option list hides its scrollbar in every browser. Kept so existing overrides don't break. | | --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 | Placeholder fill of the product tile: shown until the photo loads, and behind both fallback tiles (placeholder pictogram or title initial) used when an image URL is missing, blank or fails to load. Once a photo loads the tile is transparent. | | --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-image-radius | 8px | 8px | Corner radius of a product photo narrower than its square tile (a tall one), which is scaled to fit rather than cropped. A photo that fills the tile takes the tile's own 8px corners. | | --aia-product-placeholder-image | storefront pictogram | storefront pictogram | The pictogram used by productImageFallback: "image" (the default) when an image URL is missing, blank or fails to load. Defaults to a two-tone storefront inlined as a data URI. Set any url(...) to swap it; it is centered and contained over --aia-product-media-bg. Does not affect "initial" tiles. | | --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