@magicx-eng/ai-autocomplete-vanilla 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,7 @@ A framework-agnostic vanilla JS/TypeScript library that provides a guided AI-pow
15
15
  - **Keyboard navigation** — arrow keys, enter to submit, tab to autocomplete, backspace to un-bold the last completed param
16
16
  - **Client-side filtering** — instant substring filtering on every keystroke
17
17
  - **Option overrides** — inject or dynamically generate client-side options per suggestion type
18
+ - **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 `fetch` and `transform`
18
19
  - **Controlled & uncontrolled** — works out of the box or integrates with external state
19
20
  - **Accessible** — ARIA combobox 1.2 pattern with `role="listbox"`, `aria-activedescendant`
20
21
  - **IME-safe** — composition events are buffered so input text is committed once, after composition ends
@@ -87,6 +88,13 @@ const ac = new AIAutocomplete(container, {
87
88
  // Focus
88
89
  autoFocus: true, // focus the input on mount (Tier 1 only)
89
90
 
91
+ // Product strip (opt-in). Omit it and nothing changes: no request, no markup.
92
+ products: {
93
+ fetch: (query, signal) => myPlatform.search(query, { signal }),
94
+ transform: (raw) => mapToProducts(raw),
95
+ limit: 8,
96
+ },
97
+
90
98
  // Custom submit button (Tier 1 only)
91
99
  // - undefined (default): renders the built-in arrow button
92
100
  // - null: no submit button at all
@@ -100,6 +108,7 @@ const ac = new AIAutocomplete(container, {
100
108
  onParamsChange: (params) => { ... },
101
109
  onFocus: () => { ... },
102
110
  onBlur: () => { ... },
111
+ onProductSelect: (product) => { ... }, // see "Product strip"
103
112
 
104
113
  // Controlled mode
105
114
  value: "initial text",
@@ -116,6 +125,7 @@ ac.reset(); // Clear everything, re-fetch, and start a new se
116
125
  ac.destroy(); // Remove DOM, listeners, timers
117
126
  ac.setMode("dark"); // Switch color mode
118
127
  ac.update({ animations: false, optionsPosition: "above" });
128
+ ac.selectProduct(product); // Emit onProductSelect (Tier 3 / custom strips)
119
129
  ```
120
130
 
121
131
  > **Tier 1 auto-resets** after both Enter key and built-in submit-button clicks — your `onSubmit` runs, then the SDK clears the input and starts a new session. You don't need to call `ac.reset()` yourself in Tier 1.
@@ -128,6 +138,109 @@ ac.update({ animations: false, optionsPosition: "above" });
128
138
 
129
139
  > **Caret placement after a completion** — whenever a completed param is added (by any means — option click, exact-match typing, or re-edit), the caret lands right after the trailing space following the bold so typing can continue immediately. A space is inserted if one wasn't already there.
130
140
 
141
+ ### Product strip
142
+
143
+ Opt in with `products` and the dropdown renders a horizontal row of product
144
+ cards below the options grid. Every platform (Shopify today, others later) has
145
+ its own search endpoint and its own response shape, so the SDK owns the UI and
146
+ the integration owns only the fetching and the mapping.
147
+
148
+ ```ts
149
+ import { AIAutocomplete, type Product } from "@magicx-eng/ai-autocomplete-vanilla";
150
+
151
+ new AIAutocomplete(container, {
152
+ apiConfig: { apiKey: "..." },
153
+
154
+ products: {
155
+ // You own the request entirely — auth headers, GraphQL body, locale
156
+ // prefixes, whatever the platform needs. Honour the signal: the SDK aborts
157
+ // it as soon as a newer query supersedes this one.
158
+ fetch: (query, signal) =>
159
+ fetch("/api/2024-10/graphql.json", {
160
+ method: "POST",
161
+ headers: { "X-Shopify-Storefront-Access-Token": token },
162
+ body: JSON.stringify({ query: SEARCH_QUERY, variables: { q: query } }),
163
+ signal,
164
+ }).then((r) => r.json()),
165
+
166
+ // Pure mapping, kept out of `fetch` so you can unit-test it without a
167
+ // network. Fold it into `fetch` and return the array if you prefer.
168
+ transform: (raw) =>
169
+ raw.data.products.nodes.map((node) => ({
170
+ id: node.id,
171
+ title: node.title,
172
+ url: node.onlineStoreUrl,
173
+ imageUrl: node.featuredImage?.url ?? null,
174
+ price: formatMoney(node.priceRange.minVariantPrice), // you know the currency
175
+ vendor: node.vendor,
176
+ })),
177
+
178
+ limit: 8, // applied by the SDK, after transform
179
+ },
180
+
181
+ // Selection emits — the SDK never navigates.
182
+ onProductSelect: (product) => {
183
+ window.location.assign(product.url); // …or add to cart, or fill the input
184
+ },
185
+ });
186
+ ```
187
+
188
+ `Product` is exactly six fields; only `id`, `title` and `url` are required:
189
+
190
+ ```ts
191
+ type Product = {
192
+ id: string;
193
+ title: string;
194
+ url: string;
195
+ imageUrl?: string | null; // null renders the placeholder tile
196
+ price?: string; // pre-formatted by you
197
+ vendor?: string;
198
+ };
199
+ ```
200
+
201
+ **What the SDK guarantees**
202
+
203
+ - **One fetch cadence.** The product search rides the same debounce, the same
204
+ `AbortController` and the same version guard as `/suggest` — there is no
205
+ second timer to drift out of step. A query that never triggers a suggestions
206
+ request (e.g. one still narrowing the current option list) doesn't trigger a
207
+ product search either.
208
+ - **Empty queries never reach you.** On mount and after `reset()` the strip
209
+ simply clears.
210
+ - **Out-of-order responses are dropped.** A slow response for an older query is
211
+ never rendered over a newer one.
212
+ - **Failure is silent.** A rejected `fetch` or a throwing `transform` clears the
213
+ strip, logs once, and leaves the suggestions half untouched — no `error`
214
+ state, no `onError`, no effect on `isLoading`.
215
+ - **The two halves are independent.** The panel opens if *either* has content,
216
+ so products keep it open when option filtering empties the list, and
217
+ suggestions render normally when there are no products. `dropdownTrigger`
218
+ (`manual` / `hidden`) still gates the panel as before — products don't
219
+ overrule an explicit opt-out of an auto-opening panel.
220
+
221
+ **Selection and links.** Cards are real `<a href>` elements, so cmd/ctrl-click,
222
+ middle-click and "copy link address" behave natively. A plain left click is
223
+ intercepted (`preventDefault`) and emits `onProductSelect` instead — the SDK
224
+ never navigates on your behalf.
225
+
226
+ `product.url` is used verbatim — the SDK trusts your `transform` output and
227
+ does not sanitise it, so validate the URL there if the platform response isn't
228
+ fully under your control. (Angular additionally runs its own `[href]`
229
+ sanitiser, so unrecognised schemes like `myapp://…` are rewritten to
230
+ `unsafe:…` in that package only.)
231
+
232
+ **Accessibility.** The strip is a `role="group"` labelled "Products"; each card
233
+ is a `role="option"` in the tab order, activated with Enter or Space, with a
234
+ visible focus ring. Tabbing into the strip does not close the panel. The row
235
+ scrolls horizontally by trackpad, wheel, touch and keyboard; the page never
236
+ scrolls sideways and the scrollbar chrome is hidden in all engines.
237
+
238
+ `update({ products })` swaps the integration (and clears the current cards);
239
+ `update({ products: undefined })` turns the strip off. Either way the strip
240
+ repopulates on the *next* `/suggest` request rather than immediately — the
241
+ product search rides that one scheduler, and changing the config doesn't itself
242
+ change the query.
243
+
131
244
  #### Custom submit button
132
245
 
133
246
  ```ts
@@ -176,6 +289,8 @@ input.addEventListener("blur", () => ac.setFocused(false));
176
289
 
177
290
  Pills always render inside the dropdown in this mode. The library creates the dropdown DOM inside your container — you just provide the element and wire up your input's events.
178
291
 
292
+ The [product strip](#product-strip) works here too: pass `products` / `onProductSelect` exactly as in Tier 1 and the strip renders inside the dropdown the library owns.
293
+
179
294
  > Focus/blur wiring is required when `dropdownTrigger` is `"auto"` (the default) — the dropdown only opens while the input is focused. Without `setFocused()`, the dropdown will never open.
180
295
 
181
296
  ---
@@ -244,6 +359,7 @@ unsub();
244
359
  | `isLoading` | `boolean` | Fetch in progress. The Tier 1 / Tier 2 renderers gate the loading skeleton additionally on `!inSelectionAnimation` and `!editingParam`. |
245
360
  | `inSelectionAnimation` | `boolean` | True for the 500 ms after a user-initiated option tap so the streak animation can finish before the dropdown switches to the loading skeleton. |
246
361
  | `editingParam` | `CompletedParamState \| null` | When non-null, the user is re-editing a bold completed param; cached options remain visible and the loading skeleton is suppressed. |
362
+ | `products` | `Product[]` | Results of the latest product search (empty unless `products` is configured). See [Product strip](#product-strip) — Tier 3 consumers render their own cards and call `selectProduct(product)` to emit `onProductSelect`. |
247
363
  | `isReady` | `boolean` | Server indicates query is complete |
248
364
  | `error` | `Error \| null` | Last fetch error |
249
365
 
@@ -349,7 +465,9 @@ const offA = ac.on("change", logToAnalytics);
349
465
  const offB = ac.on("change", syncToStore);
350
466
  ```
351
467
 
352
- Events: `submit`, `error`, `change`, `paramsChange`, `stateChange`, `focus`, `blur`.
468
+ Events: `submit`, `error`, `change`, `paramsChange`, `stateChange`, `focus`, `blur`, `productSelect`.
469
+
470
+ **Errors in your callbacks are contained.** A listener that throws is logged to the console (once per event per instance) and the remaining listeners still run — the SDK's own state is unaffected, so a bug in one handler can't stall the widget. The same holds for `subscribe()` listeners and `optionOverrides` functions; an override that throws falls back to that suggestion's existing options rather than blanking the list. One exception is deliberate: if an `onSubmit` handler throws, Tier 1 skips its auto-reset so the user's typed query isn't cleared out from under a failed submit.
353
471
 
354
472
  Constructor callbacks (`onSubmit`, `onChange`, etc.) are registered once at construction as the initial listener for that event. Use `on()` for any additional or replacement listeners. **`update()` does not swap event listeners** — use `on()` for dynamic listener management.
355
473
 
@@ -391,6 +509,21 @@ Override these on the container element. All built-in defaults use `:where()` (z
391
509
  | `--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). |
392
510
  | `--aia-streak-rgb` | `99, 102, 241` | `255, 255, 255` | Comma-separated RGB triplet used to tint the option-selection streak animation (consumed via `rgba(var(--aia-streak-rgb), …)`). |
393
511
  | `--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. |
512
+ | `--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. |
513
+ | `--aia-product-gap` | `8px` | `8px` | Gap between product cards. |
514
+ | `--aia-product-bg` | `transparent` | `transparent` | Product card background. |
515
+ | `--aia-product-bg-active` | `--aia-option-bg` | `--aia-option-bg` | Product card background on hover. |
516
+ | `--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. |
517
+ | `--aia-product-placeholder-color` | `--aia-option-color` | `--aia-option-color` | Glyph color of the no-image placeholder tile. |
518
+ | `--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. |
519
+ | `--aia-product-price-color` | `--aia-option-color-selected` | `--aia-option-color-selected` | Product price text. |
520
+ | `--aia-product-vendor-color` | `--aia-option-color` | `--aia-option-color` | Product vendor line. |
521
+ | `--aia-product-focus-ring` | `--aia-option-color-selected` | `--aia-option-color-selected` | Focus ring drawn on a keyboard-focused card. |
522
+ | `--aia-products-label-color` | `--aia-option-color` | `--aia-option-color` | "Products" section label. |
523
+ | `--aia-product-title-font-size` | `12px` | `12px` | Product title font size. |
524
+ | `--aia-product-price-font-size` | `11px` | `11px` | Product price font size. |
525
+ | `--aia-product-vendor-font-size` | `10px` | `10px` | Product vendor font size. |
526
+ | `--aia-products-label-font-size` | `11px` | `11px` | Section label font size. |
394
527
  | `--aia-skeleton-bg` | `rgba(189, 189, 189, 0.25)` | `#1a1b1d` | Fill color for the loading skeleton bars and the masked text in cached pills/options. |
395
528
 
396
529
  ### Per-mode Overrides
@@ -424,7 +557,11 @@ For styling beyond the CSS variables, target these stable `data-aia-*` attribute
424
557
  | `[data-aia-pill]` | Each unfilled-suggestion pill |
425
558
  | `[data-aia-pillbar]` | Pill bar container inside the dropdown |
426
559
  | `[data-aia-option]` | Each suggestion option |
427
- | `[data-aia-dropdown]` | The dropdown root (listbox) |
560
+ | `[data-aia-dropdown]` | The dropdown root (listbox). Carries `data-aia-has-products` while the product strip has cards. |
561
+ | `[data-aia-products]` | Product strip section (label + row) |
562
+ | `[data-aia-products-row]` | The horizontally scrolling row of cards |
563
+ | `[data-aia-product]` | Each product card |
564
+ | `[data-aia-product-placeholder]` | Media tile of a card whose product has no image |
428
565
 
429
566
  Completed params render as inline `<strong>` elements inside the editor. Override their weight with `[data-aia-input] strong { font-weight: 700; }` (the built-in style uses `:where()` so any consumer selector wins without `!important`).
430
567
 
package/dist/index.d.mts CHANGED
@@ -147,6 +147,66 @@ interface AccessTokenResult {
147
147
  }
148
148
  type APIConfig = APIKeyConfig | AccessTokenConfig;
149
149
  type OptionOverrides = Record<string, (query: string) => SuggestionOption[]>;
150
+ /**
151
+ * A single product card in the dropdown's product strip.
152
+ *
153
+ * Platform-agnostic on purpose: every integration (Shopify today, others
154
+ * later) maps its own search response onto exactly these fields, so the SDK
155
+ * renders one strip regardless of where the results came from. Only `id`,
156
+ * `title` and `url` are required — the card reflows when any of the rest is
157
+ * missing. Do NOT grow this shape casually; a new field is a new contract
158
+ * every integration has to satisfy.
159
+ */
160
+ interface Product {
161
+ /** Stable identity — used as the render key, so it must be unique per result set. */
162
+ id: string;
163
+ title: string;
164
+ /** Destination for the card's `href`. Selection does not navigate; see `onProductSelect`. */
165
+ url: string;
166
+ /** Absent/null renders the placeholder tile — plenty of catalogues have no images. */
167
+ imageUrl?: string | null;
168
+ /** Pre-formatted by the integration, which is the side that knows the currency. */
169
+ price?: string;
170
+ vendor?: string;
171
+ }
172
+ /**
173
+ * Opt-in product-search wiring. Absent (the default) means the SDK never
174
+ * fetches and never renders a strip — behaviour is byte-for-byte what it was
175
+ * before products existed.
176
+ */
177
+ interface ProductsConfig {
178
+ /**
179
+ * Runs the platform's product search. The integration owns the request
180
+ * entirely — auth headers, GraphQL body, locale prefixes, whatever the
181
+ * platform needs — and must honour `signal`: the SDK aborts it the moment a
182
+ * newer query supersedes this one.
183
+ *
184
+ * Called on the SDK's own fetch cadence (the same debounce that drives
185
+ * `/suggest`), never on a timer of its own, and never with an empty query.
186
+ */
187
+ fetch: (query: string, signal: AbortSignal) => Promise<unknown>;
188
+ /**
189
+ * Maps the platform's raw payload onto `Product[]`. Kept separate from
190
+ * `fetch` so integrations can unit-test the mapping without a network; fold
191
+ * it into `fetch` and return the mapped array if you prefer.
192
+ *
193
+ * A throw here is treated exactly like a rejected fetch: the strip clears,
194
+ * the suggestions half is untouched.
195
+ *
196
+ * The output is trusted: `Product.url` goes straight onto the card's `href`
197
+ * with no sanitisation, and modifier / middle clicks are deliberately left
198
+ * to the browser. If the platform response is not fully under your control,
199
+ * validate the URL inside `transform`.
200
+ *
201
+ * One cross-framework caveat: Angular's `[href]` binding runs its own URL
202
+ * sanitiser, so a scheme it doesn't recognise (`myapp://…`) is rewritten to
203
+ * `unsafe:myapp://…` there while vanilla and React emit it verbatim. Stick
204
+ * to http/https if the same integration has to serve all three packages.
205
+ */
206
+ transform: (raw: unknown) => Product[];
207
+ /** Optional cap the SDK applies after `transform`. */
208
+ limit?: number;
209
+ }
150
210
  interface AutocompleteResult {
151
211
  query: string;
152
212
  raw_query: string;
@@ -188,6 +248,13 @@ interface CoreInputState {
188
248
  snapshot: Suggestion[];
189
249
  } | null;
190
250
  suggestions: Suggestion[];
251
+ /**
252
+ * Results of the latest product search, already transformed and capped by
253
+ * `opts.products.limit`. Always empty when `opts.products` is unconfigured.
254
+ * Cleared on an empty query and on a failed fetch/transform, so the strip
255
+ * never shows results belonging to an older query.
256
+ */
257
+ products: Product[];
191
258
  activeDropdownIndex: number;
192
259
  newParamId: string | null;
193
260
  isLoading: boolean;
@@ -289,6 +356,13 @@ interface CoreOptions {
289
356
  * arrow button. Clicks on the provided element bubble up and trigger submit.
290
357
  */
291
358
  submitButton?: HTMLElement | null;
359
+ /**
360
+ * Opt-in product strip. When set, the dropdown renders a horizontal row of
361
+ * product cards below the options grid, fed by the integration's own search
362
+ * endpoint on the SDK's existing fetch cadence. Omit it and nothing about
363
+ * the dropdown changes — no request, no markup, no layout shift.
364
+ */
365
+ products?: ProductsConfig;
292
366
  onSubmit?: (result: AutocompleteResult) => void;
293
367
  onError?: (error: Error) => void;
294
368
  onChange?: (text: string) => void;
@@ -298,6 +372,13 @@ interface CoreOptions {
298
372
  onFocus?: () => void;
299
373
  /** Called when the input loses focus (or `setFocused(false)` is called). */
300
374
  onBlur?: () => void;
375
+ /**
376
+ * Called when the user activates a product card. The SDK does NOT navigate —
377
+ * the integration decides what a selection means (open the PDP, add to cart,
378
+ * fill the input, …). Modifier / middle clicks are left to the browser so
379
+ * cmd-click and "copy link address" keep working, and don't fire this.
380
+ */
381
+ onProductSelect?: (product: Product) => void;
301
382
  value?: string;
302
383
  completedParams?: CompletedParamState[];
303
384
  /**
@@ -318,8 +399,9 @@ type AIAutocompleteEvents = {
318
399
  stateChange: [state: CoreState];
319
400
  focus: [];
320
401
  blur: [];
402
+ productSelect: [product: Product];
321
403
  };
322
- type CoreUpdateOptions = Partial<Omit<CoreOptions, "onSubmit" | "onError" | "onChange" | "onParamsChange" | "onStateChange" | "onFocus" | "onBlur">>;
404
+ type CoreUpdateOptions = Partial<Omit<CoreOptions, "onSubmit" | "onError" | "onChange" | "onParamsChange" | "onStateChange" | "onFocus" | "onBlur" | "onProductSelect">>;
323
405
  declare class AIAutocomplete {
324
406
  private inputStore;
325
407
  private store;
@@ -328,6 +410,7 @@ declare class AIAutocomplete {
328
410
  private fetchController;
329
411
  private keyboardController;
330
412
  private pillsController;
413
+ private productsController;
331
414
  private reEdit;
332
415
  private modeController;
333
416
  private container;
@@ -336,6 +419,12 @@ declare class AIAutocomplete {
336
419
  private domRefs;
337
420
  private dropdownRefs;
338
421
  private timers;
422
+ /** One per instance — see {@link ConsumerBoundary}. Shared by the emitter, `subscribe()` and `optionOverrides`. */
423
+ private boundary;
424
+ /** Identity of the raw override record the wrapped copy below was built from. */
425
+ private rawOverrides;
426
+ private wrappedOverrides;
427
+ private subscriberCount;
339
428
  private emitter;
340
429
  private sessionId;
341
430
  private readonly emitSubmit;
@@ -351,15 +440,54 @@ declare class AIAutocomplete {
351
440
  setActivePill(index: number): void;
352
441
  removeLastParam(): void;
353
442
  /**
354
- * Backspace inside a completed param: drop the param's "completed" status so
355
- * it renders as plain (un-bold) text, AND remove one grapheme before the
356
- * caretsame single-character delete a normal Backspace would do. The
357
- * remaining text stays in the input so the user can continue editing what
358
- * they had typed instead of losing the whole phrase.
443
+ * Locate the chip whose rendered text covers `offset`.
444
+ *
445
+ * Walks the derived `segments` the exact thing the editor renders rather
446
+ * than re-deriving param positions here. That keeps chip hit-testing in step
447
+ * with `deriveSegments` by construction (including params whose text repeats
448
+ * earlier in the input, and identified params that had to dodge completed
449
+ * coverage) instead of hand-mirroring its walk in a third place.
450
+ *
451
+ * Both chip kinds are returned: completed and identified render as visually
452
+ * identical chips, so both must behave atomically under Backspace. Re-edit
453
+ * remains completed-only — see `startEditingParamAtCaret`.
454
+ */
455
+ private findChipSpanAt;
456
+ /** Drop a located chip from whichever param array owns it. */
457
+ private withoutChip;
458
+ /**
459
+ * Backspace at the caret. A chip is atomic: the caret sits beside it, never
460
+ * within it, so the two positions mean different things.
461
+ *
462
+ * - Caret exactly at the chip's trailing edge (the position a Backspace over
463
+ * the following space leaves you in): delete the WHOLE chip, the way a
464
+ * chip-style token behaves. Collapses the space seam it leaves behind so
465
+ * the surrounding words don't end up double-spaced.
466
+ * - Caret strictly inside the chip (only reachable by clicking into it):
467
+ * drop the param so its text renders plain, and remove one grapheme — the
468
+ * user keeps the phrase they had and can edit it by hand.
469
+ *
470
+ * Applies to both chip kinds. Completed and identified params render
471
+ * identically, so they must delete identically; only the array the param is
472
+ * dropped from differs.
359
473
  *
360
474
  * Returns true when a param was reconciled (caller should `preventDefault`).
361
475
  */
362
476
  removeParamAtCaret(offset: number): boolean;
477
+ /**
478
+ * ArrowLeft while the caret sits at a completed pill's trailing edge selects
479
+ * the pill rather than moving the caret into it: re-edit turns on, the pill
480
+ * renders highlighted, and the dropdown shows its cached options. The caret
481
+ * stays put at the trailing edge — it never enters the pill.
482
+ *
483
+ * Identified chips are excluded: they carry no cached options and are
484
+ * deliberately not re-editable (see `renderEditable`), even though Backspace
485
+ * treats them atomically like any other chip.
486
+ *
487
+ * Returns true when re-edit started (caller should `preventDefault` so the
488
+ * browser doesn't step the caret inside the `<strong>`).
489
+ */
490
+ startEditingParamAtCaret(offset: number): boolean;
363
491
  /**
364
492
  * Set the editor caret at the given plain-text offset. Uses the core's own
365
493
  * `domRefs.input` in "full" mode; falls back to the wrapper-provided
@@ -378,10 +506,25 @@ declare class AIAutocomplete {
378
506
  handleCaretAfterInput(offset: number | null): void;
379
507
  handleCaretMove(offset: number | null): void;
380
508
  setActiveDropdownIndex(index: number): void;
509
+ /**
510
+ * Announce a product selection. The rendered cards call this on activation;
511
+ * headless consumers rendering their own strip call it themselves.
512
+ *
513
+ * Emitting is the entire behaviour — the SDK deliberately does not navigate
514
+ * to `product.url`, because only the integration knows whether a selection
515
+ * means "open the PDP", "add to cart" or "drop the title into the input".
516
+ */
517
+ selectProduct(product: Product): void;
381
518
  handleTextChange(value: string): void;
382
519
  handleKeyDown(e: KeyboardEvent): void;
383
520
  setFocused(focused: boolean): void;
384
- /** Subscribe to state changes. Listener receives the full (input + derived) shape. */
521
+ /**
522
+ * Subscribe to state changes. Listener receives the full (input + derived) shape.
523
+ *
524
+ * Consumer code, so it gets the same isolation as the `on*` events: a throw
525
+ * is contained and reported rather than unwinding into the `store.set` that
526
+ * triggered the notification. See {@link ConsumerBoundary}.
527
+ */
385
528
  subscribe(listener: (state: CoreState) => void): () => void;
386
529
  getState(): CoreState;
387
530
  get listboxId(): string;
@@ -399,6 +542,24 @@ declare class AIAutocomplete {
399
542
  selectOption(option: SuggestionOption): void;
400
543
  private startSelectionAnimationTimer;
401
544
  private fireTelemetry;
545
+ /**
546
+ * `this.opts` with every `optionOverrides` entry wrapped in the instance's
547
+ * {@link ConsumerBoundary}.
548
+ *
549
+ * The derive layer calls these functions on the SDK's stack — from
550
+ * `getState()`, and from inside the store's notification drain — so an
551
+ * un-wrapped throw would unwind whatever internal operation triggered the
552
+ * derive and abort delivery of every queued notification with it, taking the
553
+ * instance down rather than just the override. Wrapped, a failed override
554
+ * answers `undefined` and each call site falls back to the server's options.
555
+ *
556
+ * Memoized on the raw record's identity so a swapped integration is
557
+ * re-wrapped while a stable one isn't re-wrapped on every derive. Note
558
+ * `update({ optionOverrides })` only becomes visible on the next store write
559
+ * — the derived layer memoizes on inputs identity, and `update` doesn't
560
+ * invalidate it for this key. Pre-existing, and unchanged by the wrapping.
561
+ */
562
+ private deriveOpts;
402
563
  private setupContainer;
403
564
  private buildAndRenderFull;
404
565
  private buildAndRenderDropdown;
@@ -603,4 +764,4 @@ declare function withSkippedParams(completed: CompletedParam[], skipped: Skipped
603
764
  */
604
765
  declare function buildSubmitResult(text: string, completedParams: CompletedParamState[], skippedParams?: SkippedParamState[]): AutocompleteResult;
605
766
 
606
- export { AIAutocomplete, type APIConfig, type APIKeyConfig, ATTRIBUTION_URL, type AccessTokenConfig, type AccessTokenResult, type AppearanceMode, type AutocompleteRequest, type AutocompleteResponse, type AutocompleteResult, type CompletedParam, type CompletedParamState, type CoreOptions, type CoreState, type IdentifiedParam, type IdentifiedParamState, type InputItem, ModeController, type OptionOverrides, type RecentlySuggested, type RenderMode, SKIPPED_PARAM_TEXT, type Segment, type SkippedParamState, type Store, type Suggestion, type SuggestionOption, type TaskKind, buildAttributionUrl, buildQuery, buildSubmitResult, createStore, cursorIsAtEnd, extractPlainText, getCursorOffset, getFooterHint, plainTextLength, previousGraphemeBoundary, renderEditableContent, setCursorOffset, withSkippedParams };
767
+ export { AIAutocomplete, type APIConfig, type APIKeyConfig, ATTRIBUTION_URL, type AccessTokenConfig, type AccessTokenResult, type AppearanceMode, type AutocompleteRequest, type AutocompleteResponse, type AutocompleteResult, type CompletedParam, type CompletedParamState, type CoreOptions, type CoreState, type IdentifiedParam, type IdentifiedParamState, type InputItem, ModeController, type OptionOverrides, type Product, type ProductsConfig, type RecentlySuggested, type RenderMode, SKIPPED_PARAM_TEXT, type Segment, type SkippedParamState, type Store, type Suggestion, type SuggestionOption, type TaskKind, buildAttributionUrl, buildQuery, buildSubmitResult, createStore, cursorIsAtEnd, extractPlainText, getCursorOffset, getFooterHint, plainTextLength, previousGraphemeBoundary, renderEditableContent, setCursorOffset, withSkippedParams };