@magicx-eng/ai-autocomplete-vanilla 0.14.0 → 0.16.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
@@ -14,16 +14,17 @@ A framework-agnostic vanilla JS/TypeScript library that provides a guided AI-pow
14
14
  - **Access token auth** — short-lived tokens with automatic refresh, single-flight deduplication, and 401 retry
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
- - **Datepicker** — date parameters are answered with a calendar instead of an option list. Click a day or navigate with the arrow keys; the date is committed as `MONTH DAY YEAR` (e.g. `March 23 2026`). Tapping a committed date re-opens the calendar on the month it holds.
17
+ - **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.
18
18
  - **Option overrides** — inject or dynamically generate client-side options per suggestion type
19
19
  - **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`
20
20
  - **Controlled & uncontrolled** — works out of the box or integrates with external state
21
21
  - **Accessible** — ARIA combobox 1.2 pattern with `role="listbox"`, `aria-activedescendant`
22
22
  - **IME-safe** — composition events are buffered so input text is committed once, after composition ends
23
- - **Animations** — option selection streak animation, text shimmer on newly added params
24
- - **Loading skeleton** — while a fetch is in flight, the dropdown and inline pills keep the previous layout (same count and widths) with their text masked and a shimmer pulse. The skeleton is held back until the selection streak animation finishes, so taps don't visually "stutter" into loading.
23
+ - **Animations** — option press (the picked option compresses while the rest step back), text shimmer on newly added params
24
+ - **Loading skeleton** — while a fetch is in flight, the dropdown and inline pills keep the previous layout (same count and widths) with their text masked and a shimmer pulse. The skeleton is held back until the option-press animation finishes, so taps don't visually "stutter" into loading.
25
25
  - **Lightweight** — ~10 KB gzipped, styles auto-injected at runtime
26
26
  - **TypeScript first** — full type definitions shipped with the package
27
+ - **Shadow-DOM ready** — mount the container inside a shadow root and the styles and caret follow it there, with nothing to configure; page CSS can't reach the widget
27
28
  - **SSR-safe** — no top-level `document`/`window` access
28
29
 
29
30
  ## Installation
@@ -81,7 +82,7 @@ const ac = new AIAutocomplete(container, {
81
82
  // Appearance
82
83
  mode: "auto", // "light" | "dark" | "auto"
83
84
  optionsPosition: "below", // "above" | "below"
84
- animations: true, // enable streak + shimmer
85
+ animations: true, // press, shimmer, typed placeholder, staggered options, scroll arrow
85
86
  pillPlacement: "dropdown", // "inline" | "dropdown" | "hidden"
86
87
  dropdownTrigger: "auto", // "auto" | "manual" | "hidden"
87
88
  closeDropdownOnBlur: true, // false = keep dropdown open even when input loses focus
@@ -91,6 +92,11 @@ const ac = new AIAutocomplete(container, {
91
92
  // Focus
92
93
  autoFocus: true, // focus the input on mount (Tier 1 only)
93
94
 
95
+ // Where the stylesheet is injected. Defaults to the container's own root —
96
+ // the document, or the shadow root when the widget is mounted in one — so
97
+ // this is only for sending the styles somewhere else. See "Shadow DOM".
98
+ styleRoot: undefined,
99
+
94
100
  // Product strip (opt-in). Omit it and nothing changes: no request, no markup.
95
101
  products: {
96
102
  fetch: (query, signal) => myPlatform.search(query, { signal }),
@@ -287,13 +293,24 @@ the calendar for you; in Tier 3 the same data reaches your own renderer (see
287
293
  | Month arrows | Page the calendar. Paging never commits anything |
288
294
  | <kbd>→</kbd> at the end of the input | Skips the parameter, same as any other pill |
289
295
 
290
- **The committed value** is always `MONTH DAY YEAR` in English `March 23 2026`
291
- regardless of the visitor's locale, so the value you receive has one shape
292
- everywhere. It arrives as an ordinary completed parameter: bold in the input,
296
+ **The committed value** is written the way a person would write it, always in
297
+ English regardless of the visitor's locale, so the value you receive has one
298
+ vocabulary everywhere:
299
+
300
+ - a date inside the next week (today included) commits as its weekday name —
301
+ `Tuesday`. Like the sentence it sits in, that name is relative: read later,
302
+ it means the next such day;
303
+ - one further out in the current year as `MONTH DAY` — `March 23`;
304
+ - one in another year as `MONTH DAY YEAR` — `March 23 2027`.
305
+
306
+ It arrives as an ordinary completed parameter: bold in the input,
293
307
  and present in `completed_params` on submit like any other answer.
294
308
 
295
- **Re-editing** a committed date re-opens the calendar on that date's month with
296
- the day marked, so changing an answer takes one click.
309
+ **Re-editing** a committed date re-opens the calendar on the month the committed
310
+ text names, with that day marked, so changing an answer takes one click. For a
311
+ weekday name that reading is relative, so once the day it named has gone by,
312
+ re-editing marks the next such day rather than the one originally picked — the
313
+ calendar shows what the sentence says today, which is also what gets submitted.
297
314
 
298
315
  **Reading the value back.** The date arrives as text, so if you need a `Date`
299
316
  object, `parseDate` is exported for it:
@@ -301,7 +318,9 @@ object, `parseDate` is exported for it:
301
318
  ```ts
302
319
  import { parseDate } from "@magicx-eng/ai-autocomplete-vanilla";
303
320
 
304
- const due = parseDate("March 23 2026"); // Date, or null if the text isn't one of ours
321
+ // Reads all three committed shapes: a weekday name is the one date it can
322
+ // mean in the next seven days, and a yearless month-day is the current year.
323
+ const due = parseDate("March 23"); // Date, or null if the text isn't one of ours
305
324
  ```
306
325
 
307
326
  `parseLooseDate` is exported too, for the looser shapes a person types into a
@@ -362,6 +381,8 @@ The [product strip](#product-strip) works here too: pass `products` / `onProduct
362
381
 
363
382
  > 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.
364
383
 
384
+ If your input is a scrollable `contenteditable` rather than the `<input>` above, see [Keeping the caret visible](#keeping-the-caret-visible) — placing the caret is yours to do in this mode, and so is scrolling to it.
385
+
365
386
  ---
366
387
 
367
388
  ## Tier 3: Headless
@@ -412,7 +433,7 @@ unsub();
412
433
  > **Custom (rich-text) editors:** the wiring above assumes `myInput` is a `<textarea>`/`<input>`, whose caret the library reads directly. If you're driving a contentEditable or rich-text editor instead, also call `ac.handleCaretMove(offset)` on selection changes (with the caret as a plain-text offset) so arrow keys can move from the input into the dropdown.
413
434
 
414
435
 
415
- > **Datepicker in a custom UI.** For a date parameter, `state.activeFormatType` is `"date"` and `state.filteredOptions` holds that month's day cells. Each cell's `text` is the date it commits (`"March 23 2026"`), so rendering them as a plain list already works — `selectOption(cell)` behaves exactly as it does for an option. To draw an actual calendar, read `state.dateView` for the month on show, `cellDay(cell)` for the number to paint, and call `showPreviousMonth()` / `showNextMonth()` to page. Cells that pad the start and end of the month have `is_tappable: false`.
436
+ > **Datepicker in a custom UI.** For a date parameter, `state.activeFormatType` is `"date"` and `state.filteredOptions` holds that month's day cells. Each cell's `text` is the date it commits (`"March 23"`, or `"Tuesday"` inside the next week), so rendering them as a plain list already works — `selectOption(cell)` behaves exactly as it does for an option. To draw an actual calendar, read `state.dateView` for the month on show, `cellDay(cell)` for the number to paint, and call `showPreviousMonth()` / `showNextMonth()` to page. Cells that pad the start and end of the month have `is_tappable: false`.
416
437
  ### State shape (`CoreState`)
417
438
 
418
439
  | Field | Type | Description |
@@ -421,7 +442,7 @@ unsub();
421
442
  | `completedParams` | `CompletedParamState[]` | Filled parameters |
422
443
  | `suggestions` | `Suggestion[]` | All suggestions from server (including placeholder type) |
423
444
  | `actionableSuggestions` | `Suggestion[]` | Non-placeholder suggestions (the pills) |
424
- | `filteredOptions` | `SuggestionOption[]` | Options for the active suggestion, filtered by current query. For a date parameter these are the visible month's day cells instead — each cell's `text` is the date it commits (`"March 23 2026"`), and cells padding the start/end of the month have `is_tappable: false`. |
445
+ | `filteredOptions` | `SuggestionOption[]` | Options for the active suggestion, filtered by current query. For a date parameter these are the visible month's day cells instead — each cell's `text` is the date it commits (`"March 23"`, or `"Tuesday"` inside the next week), and cells padding the start/end of the month have `is_tappable: false`. |
425
446
  | `activeFormatType` | `"options" \| "date"` | How the active parameter is answered: pick an option, or pick a date. `"date"` shows a calendar instead of the option list. Typing does not filter it — the text is treated as a new query. |
426
447
  | `dateView` | `DateMonthView \| null` | The month the calendar is showing (`{ year, month }`, 0-based month). Null unless `activeFormatType` is `"date"`. |
427
448
  | `segments` | `Segment[]` | Input text split into typed text vs completed params — completed segments render as bold `<strong>` runs inside the editor |
@@ -430,7 +451,7 @@ unsub();
430
451
  | `isDropdownOpen` | `boolean` | Whether the dropdown should be visible |
431
452
  | `newParamId` | `string \| null` | ID of the most recently added param (for shimmer animation) |
432
453
  | `isLoading` | `boolean` | Fetch in progress. The Tier 1 / Tier 2 renderers gate the loading skeleton additionally on `!inSelectionAnimation` and `!editingParam`. |
433
- | `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. |
454
+ | `inSelectionAnimation` | `boolean` | True for the 500 ms after a user-initiated option tap so the press animation can finish before the dropdown switches to the loading skeleton. |
434
455
  | `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. |
435
456
  | `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`. |
436
457
  | `isReady` | `boolean` | Server indicates query is complete |
@@ -470,6 +491,30 @@ The pattern for any framework:
470
491
  4. Forward user events (input, keydown, click) → core actions
471
492
  5. Destroy on unmount
472
493
 
494
+ ### Keeping the caret visible
495
+
496
+ Tier 1 handles this for you. In Tiers 2 and 3 you own the editor, so if you
497
+ render your own `contenteditable` that can scroll — held to one line with
498
+ `overflow-x`, or capped in height — call `scrollCaretIntoView` after you move
499
+ the caret yourself:
500
+
501
+ ```ts
502
+ import { scrollCaretIntoView } from "@magicx-eng/ai-autocomplete-vanilla";
503
+
504
+ // ...right after placing the caret
505
+ scrollCaretIntoView(editorElement);
506
+ ```
507
+
508
+ A browser scrolls an editor to the caret while the user types, but not when the
509
+ caret is moved by script — so answering a parameter can otherwise leave the
510
+ caret, and everything typed next, outside the visible box.
511
+
512
+ It scrolls only the nearest scrollable box around the element you pass, and
513
+ never the page. It is a no-op when nothing overflows, when the caret is already
514
+ visible, and when the caret is not inside the element you pass — which includes
515
+ a plain `<input>` or `<textarea>`, whose caret the document selection cannot
516
+ reach. Those are the browser's to scroll, not this helper's.
517
+
473
518
  ---
474
519
 
475
520
  ## API Reference
@@ -554,6 +599,25 @@ Constructor callbacks (`onSubmit`, `onChange`, etc.) are registered once at cons
554
599
 
555
600
  Styles are auto-injected at runtime (Tier 1 and Tier 2). No CSS import needed. The component ships built-in light and dark defaults.
556
601
 
602
+ ### Shadow DOM
603
+
604
+ Mount the container inside a shadow root and the widget works there — the stylesheet is injected into that root rather than `document.head`, and the caret is read from the root that owns the editor:
605
+
606
+ ```js
607
+ const host = document.querySelector("#autocomplete-host");
608
+ const root = host.attachShadow({ mode: "open" });
609
+ const container = document.createElement("div");
610
+ root.appendChild(container);
611
+
612
+ const ac = new AIAutocomplete(container, { apiConfig: { apiKey: "..." } });
613
+ ```
614
+
615
+ Nothing to configure: the root is derived from the container. Light-DOM mounts are unaffected, and a page can hold both — each root gets the stylesheet.
616
+
617
+ This is the way to embed the widget in a page whose CSS you don't control. In the light DOM every page rule reaches the widget, and the ones that reach it by element (`footer`, `kbd`, `a`) or with `!important` win over the SDK's own classes. A shadow root ends that: page rules stop at the boundary. Note that the same boundary stops **your** page CSS too, so the [selector hooks](#selector-hooks) below have to be applied from inside the shadow root (or through the `--aia-*` variables, which inherit across it — set them on the host).
618
+
619
+ `styleRoot` overrides where the styles land, for the rare mount whose container root isn't where they should go. It's applied once, at construction.
620
+
557
621
  ### CSS Variables
558
622
 
559
623
  Override these on the container element. All built-in defaults use `:where()` (zero specificity) — your overrides always win without `!important`.
@@ -582,13 +646,17 @@ Override these on the container element. All built-in defaults use `:where()` (z
582
646
  | `--aia-footer-chip-bg` | `--aia-surface` at 65% | `--aia-surface` at 65% | Fill behind the footer's keyboard hint and AI-Autocomplete badge. The option list scrolls under the footer, so this keeps both legible over the row passing behind them. `transparent` on the glass surface. |
583
647
  | `--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. |
584
648
  | `--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). |
585
- | `--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), …)`). |
586
- | `--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. |
649
+ | `--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. |
587
650
  | `--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. |
588
651
  | `--aia-product-gap` | `8px` | `8px` | Gap between product cards. |
589
652
  | `--aia-product-bg` | `transparent` | `transparent` | Product card background. |
590
653
  | `--aia-product-bg-active` | `--aia-option-bg` | `--aia-option-bg` | Product card background on hover. |
591
654
  | `--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. |
655
+ | `--aia-scroll-arrow-bg` | `--aia-surface` | `--aia-surface` | Fill of the "more below" arrow — the round button that rises at the bottom of the option list while there is more to scroll to. |
656
+ | `--aia-scroll-arrow-color` | `--aia-option-color` | `--aia-option-color` | Chevron color of the arrow (`--aia-scroll-arrow-color-hover` on hover). |
657
+ | `--aia-scroll-arrow-border` | `--aia-dropdown-border` | `--aia-dropdown-border` | Hairline around the arrow. |
658
+ | `--aia-scroll-arrow-shadow` | `0 2px 8px rgba(0,0,0,0.12)` | `0 2px 8px rgba(0,0,0,0.12)` | Elevation of the arrow. |
659
+ | `--aia-placeholder-fade` | `120ms` | `120ms` | Fade-out of the outgoing placeholder phrase when the starting-state placeholder changes (the incoming one types itself in). |
592
660
  | `--aia-product-placeholder-color` | `--aia-option-color` | `--aia-option-color` | Glyph color of the no-image placeholder tile. |
593
661
  | `--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. |
594
662
  | `--aia-product-price-color` | `--aia-option-color-selected` | `--aia-option-color-selected` | Product price text. |
@@ -599,9 +667,10 @@ Override these on the container element. All built-in defaults use `:where()` (z
599
667
  | `--aia-product-price-font-size` | `11px` | `11px` | Product price font size. |
600
668
  | `--aia-product-vendor-font-size` | `10px` | `10px` | Product vendor font size. |
601
669
  | `--aia-products-label-font-size` | `11px` | `11px` | Section label font size. |
602
- | `--aia-date-cell-size` | `36px` | `36px` | Size of a day's square the box that carries the highlight, the today ring and the selected fill. |
603
- | `--aia-date-row-height` | `40px` | `40px` | Height of one week row. Lower it to fit a 6-week month in a shorter dropdown. |
604
- | `--aia-date-cell-font-size` | `14px` | `14px` | Day-number font size. |
670
+ | `--aia-date-panel-width` | `312px` | `312px` | Max width of the whole dropdown panel while the calendar is up the panel narrows to calendar size. |
671
+ | `--aia-date-cell-size` | `26px` | `26px` | Size of a day's square the box that carries the highlight, the today ring and the selected fill. |
672
+ | `--aia-date-row-height` | `28px` | `28px` | Height of one week row. Lower it to fit a 6-week month in a shorter dropdown. |
673
+ | `--aia-date-cell-font-size` | `13px` | `13px` | Day-number font size. |
605
674
  | `--aia-date-month-font-size` | `14px` | `14px` | Font size of the "March 2026" header. |
606
675
  | `--aia-date-weekday-font-size` | `11px` | `11px` | Font size of the S/M/T/W/T/F/S column letters. |
607
676
  | `--aia-date-today-ring` | `--aia-option-color` | `--aia-option-color` | Outline drawn around today's date. |
@@ -641,6 +710,7 @@ For styling beyond the CSS variables, target these stable `data-aia-*` attribute
641
710
  | `[data-aia-pill-scroll]` | Scrollable pill region inside the bar — carries the horizontal scroll and right-edge fade mask |
642
711
  | `[data-aia-skip]` | The pill bar's trailing "skip" button. Tune via `--aia-skip-font-size` / `--aia-skip-color` / `--aia-skip-color-hover` / `--aia-skip-hover-bg` |
643
712
  | `[data-aia-option]` | Each suggestion option |
713
+ | `[data-aia-scroll-arrow]` | The "more below" arrow on the dropdown — carries `data-aia-visible` while shown |
644
714
  | `[data-aia-dropdown]` | The dropdown root (listbox). Carries `data-aia-has-products` while the product strip has cards. |
645
715
  | `[data-aia-datepicker]` | The calendar, rendered in place of the option list for a date parameter |
646
716
  | `[data-aia-date-month]` | The "March 2026" header label |
package/dist/index.d.mts CHANGED
@@ -229,12 +229,35 @@ declare const WEEKDAY_LABELS: string[];
229
229
  * Greenwich would produce `2026-03-22`.
230
230
  */
231
231
  declare function isoDate(d: Date): string;
232
- /** The committed form of a date: "March 23 2026". */
232
+ /**
233
+ * The committed form of a date — written the way a person would, since the
234
+ * text lands verbatim in the user's own sentence:
235
+ *
236
+ * - today through six days out: the weekday alone, "Tuesday". The window
237
+ * stops before day seven so every weekday name still means exactly one
238
+ * date when read back.
239
+ * - further out, same year: "March 23" — the year restates what "this year"
240
+ * already says.
241
+ * - a different year: "March 23 2027".
242
+ */
233
243
  declare function formatDate(d: Date): string;
234
244
  /**
235
245
  * Inverse of {@link formatDate}, for re-opening the calendar on the month a
236
- * completed date param already holds. Returns null for anything elsethe
237
- * caller falls back to the current month.
246
+ * completed date param already holds. Reads all three committed shapesa
247
+ * weekday name resolves to its one date in the next seven days, and a
248
+ * yearless month-day to the current year, mirroring how they were written.
249
+ * Returns null for anything else — the caller falls back to the current
250
+ * month.
251
+ *
252
+ * Deliberately RELATIVE, like the sentence it reads: the committed text is
253
+ * the single source of truth, and "Schedule a campaign Tuesday" means the
254
+ * next Tuesday to everyone who reads it — the user re-reading their own
255
+ * sentence, this parser, and the server parsing the submitted query alike.
256
+ * So once the originally-picked Tuesday has gone by, re-editing marks the
257
+ * date the sentence NOW names, a week later — not the stale pick. Anchoring
258
+ * to the pick would make the calendar disagree with the sentence above it
259
+ * (and with what the server will act on), which is the worse wrong. Pinned
260
+ * by the clock-advance test in datepicker.test.ts.
238
261
  */
239
262
  declare function parseDate(text: string | undefined | null): Date | null;
240
263
  /** "March 2026" — the calendar header. */
@@ -253,11 +276,15 @@ declare function addMonths(view: DateMonthView, delta: number): DateMonthView;
253
276
  * the grid's own column-major traversal already *is* calendar navigation
254
277
  * (→ = +1 day, ↓ = +7 days).
255
278
  *
256
- * Each day cell's `text` is the committed value ("March 23 2026"), NOT its
257
- * label: `computeSelectionPatch` commits `option.text` verbatim, so the value
258
- * has to live there. The renderer reads `metadata.aiaDay` for the "23" it
259
- * paints. Padding cells are non-tappable, which is what stops arrow navigation
279
+ * Each day cell's `text` is the committed value ("March 23", or "Tuesday"
280
+ * inside the next week — see {@link formatDate}), NOT its label:
281
+ * `computeSelectionPatch` commits `option.text` verbatim, so the value has to
282
+ * live there. The renderer reads `metadata.aiaDay` for the "23" it paints. Padding cells are non-tappable, which is what stops arrow navigation
260
283
  * from landing on them.
284
+ *
285
+ * Days before today stay tappable — the calendar de-emphasizes the past, it
286
+ * does not forbid it. The renderers compare each cell's ISO date against
287
+ * today's and paint past days dimmed; picking one still commits normally.
261
288
  */
262
289
  declare function buildDateOptions(view: DateMonthView): SuggestionOption[];
263
290
  /**
@@ -366,7 +393,7 @@ interface CoreInputState {
366
393
  /** Current caret offset within the editor. Tracked via DOM `selectionchange`. */
367
394
  caretOffset: number | null;
368
395
  /**
369
- * True for ~500ms after a user-initiated option selection so the streak
396
+ * True for ~500ms after a user-initiated option selection so the press
370
397
  * animation can finish before the dropdown switches to its loading skeleton.
371
398
  * Set by selectOption / ReEditManager.selectOption, cleared by a timer.
372
399
  */
@@ -503,6 +530,15 @@ interface CoreOptions {
503
530
  showSkipButton?: boolean;
504
531
  /** Render mode. Default: "full". */
505
532
  renderMode?: RenderMode;
533
+ /**
534
+ * Where the SDK's stylesheet is injected. Defaults to the root the container
535
+ * is mounted in — the document, or the shadow root when the widget is inside
536
+ * one — so mounting into a shadow root is styled correctly with no
537
+ * configuration. Set this only when the styles need to land somewhere else
538
+ * than the container's own root. Applied once at construction; ignored by
539
+ * `update()`, and by `renderMode: "headless"`, which injects no styles.
540
+ */
541
+ styleRoot?: Document | ShadowRoot;
506
542
  /**
507
543
  * Identifies which SDK surface is in use, for telemetry only. The hook can't
508
544
  * distinguish on its own — the Tier 1 component sets "full-sdk" when wiring
@@ -563,7 +599,7 @@ type AIAutocompleteEvents = {
563
599
  blur: [];
564
600
  productSelect: [product: Product];
565
601
  };
566
- type CoreUpdateOptions = Partial<Omit<CoreOptions, "onSubmit" | "onError" | "onChange" | "onParamsChange" | "onStateChange" | "onFocus" | "onBlur" | "onProductSelect">>;
602
+ type CoreUpdateOptions = Partial<Omit<CoreOptions, "onSubmit" | "onError" | "onChange" | "onParamsChange" | "onStateChange" | "onFocus" | "onBlur" | "onProductSelect" | "styleRoot">>;
567
603
  declare class AIAutocomplete {
568
604
  private inputStore;
569
605
  private store;
@@ -891,6 +927,49 @@ declare function resolveIdentifiedDate(param: {
891
927
  isoDate?: unknown;
892
928
  }, opts?: LooseDateOptions): string | null;
893
929
 
930
+ /**
931
+ * Entrance choreography for the dropdown's option rows.
932
+ *
933
+ * When a new set of options lands, each row fades in while rising into place,
934
+ * and the rows start one after another from the input's side outward — the
935
+ * row nearest the input first. The reference (the RB2B support box's
936
+ * suggestion bubbles, 2026-08-19): opacity 0→1 over ~150 ms, a ~16 px rise
937
+ * easing out over ~280 ms, consecutive rows ~80–110 ms apart.
938
+ *
939
+ * The per-row motion is CSS (the option rule in each package's stylesheet —
940
+ * keep the three copies identical; the parity test compares them). This module
941
+ * owns the *timing numbers* and the per-row delay, so vanilla, React and
942
+ * Angular can't drift onto three different cascades. `OPTION_ENTER_RISE_MS` and
943
+ * `OPTION_ENTER_FADE_MS` must match the durations declared in those rules.
944
+ */
945
+ /** Gap between consecutive rows starting their entrance. */
946
+ declare const OPTION_ENTER_STAGGER_MS = 80;
947
+ /** Duration of a row's opacity fade — mirrors the stylesheets' entrance rule. */
948
+ declare const OPTION_ENTER_FADE_MS = 150;
949
+ /** Duration of a row's rise into place — mirrors the stylesheets' entrance rule. */
950
+ declare const OPTION_ENTER_RISE_MS = 280;
951
+ /** Distance a row rises from, in CSS px — mirrors the stylesheets' entrance rule. */
952
+ declare const OPTION_ENTER_RISE_PX = 16;
953
+ /** Inline custom property each option carries with its own start delay. */
954
+ declare const OPTION_ENTER_DELAY_VAR = "--aia-option-enter-delay";
955
+ /**
956
+ * Start delay (ms) for the option at `index` in a grid of `count` options laid
957
+ * out in `cols` columns, when the dropdown sits `optionsPosition` the input.
958
+ *
959
+ * Rows, not cells, are the unit: the cells of one row share a start so a
960
+ * two-column layout reads as rows appearing, not as a zig-zag. "below" starts
961
+ * at the top row (the one against the input); "above" starts at the bottom row
962
+ * for the same reason — the stack is reversed there and the last row is the
963
+ * one nearest the input.
964
+ */
965
+ declare function optionEnterDelayMs(index: number, cols: number, count: number, optionsPosition?: "above" | "below"): number;
966
+ /**
967
+ * How long after the options render until the last row has settled — the
968
+ * moment anything that measures the grid (the scroll arrow) can trust its
969
+ * geometry again, since a row mid-rise still extends the scrollable area.
970
+ */
971
+ declare function optionsEntranceDurationMs(count: number, cols: number): number;
972
+
894
973
  /**
895
974
  * Rows/columns policy for the dropdown's options grid.
896
975
  *
@@ -912,11 +991,61 @@ interface OptionsGridLayout {
912
991
  }
913
992
  /**
914
993
  * - Mobile: one column, five rows visible (scroll past five).
915
- * - Web: one column, four rows visible by default — but when there are exactly
916
- * five or six options, two balanced columns (so 5/6 fit without scrolling).
917
- * Columns fill row-major, so the two columns end up 3/2 (five) or 3/3 (six).
994
+ * - Web: one column, four rows visible (scroll past four).
995
+ *
996
+ * This is the measurement-free baseline (and the only layout mobile ever
997
+ * uses). On web, `planOptionsGrid` upgrades five-plus options to two columns
998
+ * when the rendered rows prove they fit — the upgrade needs the DOM, so it
999
+ * can't live here.
918
1000
  */
919
1001
  declare function computeOptionsGridLayout(count: number, isMobile: boolean): OptionsGridLayout;
1002
+ interface OptionsGridPlan extends OptionsGridLayout {
1003
+ /**
1004
+ * Value for `grid-template-columns`. Tracks carry no inner spaces (the
1005
+ * keyboard controller counts tracks by splitting on spaces).
1006
+ */
1007
+ template: string;
1008
+ /**
1009
+ * Indices of the options on the last visible row of a scrollable web grid —
1010
+ * their text gets the fade that signals more options below. Empty on mobile
1011
+ * and whenever everything is already visible.
1012
+ */
1013
+ scrollHintIndices: number[];
1014
+ }
1015
+ /**
1016
+ * Whether measuring the rendered rows could change the plan at all. Measuring
1017
+ * clones every row and forces a layout, and renders happen per keystroke —
1018
+ * so the callers skip it whenever `planOptionsGrid` would ignore the result
1019
+ * anyway (mobile, or fewer options than the two-column minimum).
1020
+ */
1021
+ declare function needsOptionsGridMeasurement(count: number, isMobile: boolean): boolean;
1022
+ /**
1023
+ * The full layout decision, measurements included.
1024
+ *
1025
+ * Web, five-plus options: two columns of three visible rows — but only when
1026
+ * every option provably fits on one line. Rows fill row-major (even indices
1027
+ * left, odd right), each column is as wide as its widest option, and the
1028
+ * columns may be unequal: the template splits the width in proportion to the
1029
+ * two column maxima, so whenever `left + right <= gridWidth`, each column gets
1030
+ * at least what its widest row needs. When the pair doesn't fit — or there are
1031
+ * no usable measurements (SSR, hidden grid, loading skeletons) — the layout
1032
+ * stays one scrollable column.
1033
+ *
1034
+ * `rowWidths` are single-line pixel widths of the rendered rows (see
1035
+ * `measureOptionsGrid`); `gridWidth` is the grid's content width.
1036
+ */
1037
+ declare function planOptionsGrid(count: number, isMobile: boolean, rowWidths: number[] | null, gridWidth: number | null): OptionsGridPlan;
1038
+ /**
1039
+ * Measure the rendered option rows for `planOptionsGrid`: each row's
1040
+ * single-line width (a hidden `max-content` + `nowrap` clone, so real classes,
1041
+ * fonts, icons, tags and padding are all in the number), and the width the
1042
+ * columns have to share — the grid's content box less its column gap.
1043
+ * Returns null when there's nothing to measure.
1044
+ */
1045
+ declare function measureOptionsGrid(grid: HTMLElement): {
1046
+ rowWidths: number[];
1047
+ gridWidth: number;
1048
+ } | null;
920
1049
  /**
921
1050
  * `grid-template-columns` for a fixed column count. Space-separated
922
1051
  * `minmax(0,1fr)` tracks (no `repeat()`, no inner spaces) so rows fill
@@ -952,6 +1081,10 @@ declare function getCursorOffset(root: HTMLElement): number | null;
952
1081
  * with no following text node, we use `setStartAfter(strong)` so the caret
953
1082
  * sits OUTSIDE the bold subtree — otherwise a caret at the end of a strong's
954
1083
  * text is still "inside" the strong, which would falsely trigger re-edit mode.
1084
+ *
1085
+ * Also scrolls the caret back into view (see `scrollCaretIntoView`) — this is
1086
+ * the single caret-placement choke point, and a scripted caret move does not
1087
+ * get the browser's own scroll-to-caret behaviour.
955
1088
  */
956
1089
  declare function setCursorOffset(root: HTMLElement, offset: number): void;
957
1090
  /**
@@ -966,6 +1099,66 @@ declare function cursorIsAtEnd(root: HTMLElement): boolean;
966
1099
  */
967
1100
  declare function previousGraphemeBoundary(text: string, offset: number): number;
968
1101
 
1102
+ /**
1103
+ * Scroll-to-caret for the editor's own scroll container.
1104
+ *
1105
+ * The editor is a scroll container (`overflow-y: auto` under a height cap in
1106
+ * the core stylesheet; a consumer holding it to one line adds `overflow-x`).
1107
+ * Browsers scroll such a box to the caret as the user types, but not when the
1108
+ * caret is moved by script — which the SDK does on every render that actually
1109
+ * rewrites the editable's content (`renderEditable` early-returns on an
1110
+ * unchanged segment key, and restores the caret only while focused).
1111
+ * Everything here exists to close that gap.
1112
+ */
1113
+ /**
1114
+ * Scroll the caret back into view inside the editor's own scroll container.
1115
+ *
1116
+ * Browsers do this themselves while the user types, but not when the caret is
1117
+ * moved by script — so every programmatic caret placement (option selection,
1118
+ * caret restore after a re-render, controlled-mode `setValue`) would otherwise
1119
+ * leave the caret, and everything typed next, outside the visible box.
1120
+ *
1121
+ * Exactly one box is scrolled, by writing its own `scrollLeft` / `scrollTop`:
1122
+ * the nearest scrollable ancestor of the editable, which inside the widget's
1123
+ * own markup is the editor. `scrollIntoView` is deliberately not used — it
1124
+ * would walk the whole ancestor chain and the page with it. When the widget
1125
+ * does not own the editable's ancestry (a consumer-rendered editor in
1126
+ * `"dropdown"` / `"headless"` mode) that nearest box may be one of the
1127
+ * consumer's, which is what the browser would scroll for typed input anyway;
1128
+ * nothing above it moves either way. A no-op when nothing overflows, when the
1129
+ * caret is already visible, or when the caret has no measurable box.
1130
+ */
1131
+ declare function scrollCaretIntoView(root: HTMLElement): void;
1132
+
1133
+ /**
1134
+ * Types the starting-state placeholder into the input one character at a
1135
+ * time instead of popping it in whole.
1136
+ *
1137
+ * The reference (the RB2B support box, 2026-08-19): ~33 ms per character, no
1138
+ * caret, a brief hold on word boundaries; when the phrase changes the old
1139
+ * one fades out (~120 ms), the field sits empty for a beat (~50 ms), then the
1140
+ * new phrase types in from nothing. Characters appear at full opacity — the
1141
+ * only fade is the outgoing phrase.
1142
+ *
1143
+ * Shared by every package: vanilla, React and Angular all render the
1144
+ * placeholder through `renderEditableContent`, which hands the target text
1145
+ * to `syncPlaceholder` below. The typed prefix lives in `data-placeholder`
1146
+ * (the stylesheets render it via `::before`), so nothing enters the
1147
+ * contentEditable's DOM and the core's notion of the placeholder — which
1148
+ * drives Tab-to-accept and prefix-typing suppression — stays the full phrase
1149
+ * at all times.
1150
+ */
1151
+ /** Delay between characters. */
1152
+ declare const PLACEHOLDER_TYPE_MS = 33;
1153
+ /** Extra hold after a word boundary (space / hyphen). */
1154
+ declare const PLACEHOLDER_WORD_PAUSE_MS = 70;
1155
+ /** Fade-out of the outgoing phrase — must match `--aia-placeholder-fade` in the stylesheets. */
1156
+ declare const PLACEHOLDER_FADE_OUT_MS = 120;
1157
+ /** Empty beat between the fade-out and the next phrase typing in. */
1158
+ declare const PLACEHOLDER_SWAP_GAP_MS = 50;
1159
+ /** Set on the input while the outgoing phrase fades; the stylesheets fade `::before` on it. */
1160
+ declare const PLACEHOLDER_LEAVING_ATTR = "data-aia-placeholder-leaving";
1161
+
969
1162
  interface RenderEditableArgs {
970
1163
  input: HTMLElement;
971
1164
  segments: Segment[];
@@ -990,6 +1183,56 @@ interface RenderEditableArgs {
990
1183
  */
991
1184
  declare function renderEditableContent(args: RenderEditableArgs): void;
992
1185
 
1186
+ /**
1187
+ * The "more below" arrow for the options grid.
1188
+ *
1189
+ * When the option list is taller than its scroll box, a small round button
1190
+ * with a down chevron sits at the bottom-centre of the list. It slides up into
1191
+ * view from behind the dropdown's lower edge (the footer, when the dropdown
1192
+ * opens below the input) the moment there is more to scroll to, slides back
1193
+ * down once the list is scrolled to its end, and scrolls the list a page on
1194
+ * click. The reference (the RB2B support panel, 2026-08-19): a white 32 px
1195
+ * disc that rises from behind the composer over ~150–180 ms, no fade.
1196
+ *
1197
+ * The DOM differs per package (vanilla builds the button here; React and
1198
+ * Angular render their own markup with the same classes and data attributes),
1199
+ * so the *behaviour* lives in this one controller and each package hands it
1200
+ * its dropdown, grid and button elements:
1201
+ *
1202
+ * - `update()` after every render — re-measures where the grid's bottom edge
1203
+ * is and re-evaluates visibility. The disc seats itself in the grid's
1204
+ * reserved fade band when there is one (below-mode with the footer), and
1205
+ * floats a small gap above the grid's edge in layouts that zero the band
1206
+ * (`optionsPosition="above"`, product strip). A new
1207
+ * option group (`data-aia-group` on the grid changed) keeps the arrow hidden
1208
+ * until the rows' entrance animation has settled, because a row mid-rise
1209
+ * still extends the scrollable area and would flash the arrow on.
1210
+ * - The controller listens for the grid's own `scroll` and size changes itself.
1211
+ * - `destroy()` when the grid or dropdown goes away.
1212
+ */
1213
+ declare const SCROLL_ARROW_CLASS = "magicx-aia-scroll-arrow";
1214
+ /** Stable styling hook (module class names aren't part of the API). */
1215
+ declare const SCROLL_ARROW_ATTR = "data-aia-scroll-arrow";
1216
+ /** Present on the button while it is shown. The stylesheets slide it in on this. */
1217
+ declare const SCROLL_ARROW_VISIBLE_ATTR = "data-aia-visible";
1218
+ /** Inline custom property: how far the button's bottom edge sits above the dropdown's padding edge. */
1219
+ declare const SCROLL_ARROW_BOTTOM_VAR = "--aia-scroll-arrow-bottom";
1220
+ declare const SCROLL_ARROW_LABEL = "Scroll down for more options";
1221
+ interface ScrollArrowController {
1222
+ /** Re-measure and re-evaluate. Call after every render of the dropdown. */
1223
+ update(): void;
1224
+ destroy(): void;
1225
+ }
1226
+ interface ScrollArrowArgs {
1227
+ /** The dropdown panel — the button's containing block; its lower edge clips the hidden button. */
1228
+ dropdown: HTMLElement;
1229
+ /** The options grid (the scroll box). */
1230
+ grid: HTMLElement;
1231
+ /** The arrow button, already in the dropdown. */
1232
+ button: HTMLElement;
1233
+ }
1234
+ declare function attachScrollArrow({ dropdown, grid, button, }: ScrollArrowArgs): ScrollArrowController;
1235
+
993
1236
  type Listener<S> = (next: S, prev: S) => void;
994
1237
  interface Store<S> {
995
1238
  get: () => S;
@@ -1060,6 +1303,23 @@ declare class ModeController {
1060
1303
  private detachListener;
1061
1304
  }
1062
1305
 
1306
+ /**
1307
+ * The dropdown pill-bar label for a parameter that has no suggestion behind it.
1308
+ *
1309
+ * Every pill normally shows its suggestion's `text` — a phrase written to be
1310
+ * read, "Due date". A date the server identified in the user's own words
1311
+ * ("september 5th") has no suggestion to borrow one from, only the wire `type`,
1312
+ * so the type is read back into words instead: `travel_date` and `travelDate`
1313
+ * both become "travel date". Without this the raw type is the label, both while
1314
+ * the calendar is open over the span and again if that parameter is re-edited
1315
+ * after being answered.
1316
+ *
1317
+ * Lower-casing costs nothing here — every package's pill CSS sets
1318
+ * `text-transform: uppercase`, so a label's original case never reaches the
1319
+ * screen either way.
1320
+ */
1321
+ declare function identifiedParamLabel(type: string): string;
1322
+
1063
1323
  /**
1064
1324
  * Sentinel `text` marking a `completed_params` entry the user skipped (→)
1065
1325
  * rather than filled. Sent regardless of `maskCompletedText` — it's a fixed
@@ -1092,4 +1352,4 @@ declare function withSkippedParams(completed: CompletedParam[], skipped: Skipped
1092
1352
  */
1093
1353
  declare function buildSubmitResult(text: string, completedParams: CompletedParamState[], skippedParams?: SkippedParamState[]): AutocompleteResult;
1094
1354
 
1095
- 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 DateMonthView, type FormatType, type IdentifiedParam, type IdentifiedParamState, type InputItem, type LooseDateOptions, ModeController, OPTIONS_GRID_MOBILE_QUERY, type OptionOverrides, type OptionsGridLayout, type Product, type ProductsConfig, type RecentlySuggested, type RenderMode, SKIPPED_PARAM_TEXT, type Segment, type SkippedParamState, type Store, type Suggestion, type SuggestionOption, type TaskKind, WEEKDAY_LABELS, addMonths, buildAttributionUrl, buildDateOptions, buildQuery, buildSubmitResult, cellDay, cellIso, computeOptionsGridLayout, createStore, cursorIsAtEnd, extractPlainText, formatDate, getCursorOffset, getFooterHint, isOptionsGridMobileViewport, isoDate, monthLabel, optionsGridTemplateColumns, parseDate, parseLooseDate, plainTextLength, previousGraphemeBoundary, renderEditableContent, resolveFormatType, resolveIdentifiedDate, selectedIsoFromText, setCursorOffset, withSkippedParams };
1355
+ 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 DateMonthView, type FormatType, type IdentifiedParam, type IdentifiedParamState, type InputItem, type LooseDateOptions, ModeController, OPTIONS_GRID_MOBILE_QUERY, OPTION_ENTER_DELAY_VAR, OPTION_ENTER_FADE_MS, OPTION_ENTER_RISE_MS, OPTION_ENTER_RISE_PX, OPTION_ENTER_STAGGER_MS, type OptionOverrides, type OptionsGridLayout, type OptionsGridPlan, PLACEHOLDER_FADE_OUT_MS, PLACEHOLDER_LEAVING_ATTR, PLACEHOLDER_SWAP_GAP_MS, PLACEHOLDER_TYPE_MS, PLACEHOLDER_WORD_PAUSE_MS, type Product, type ProductsConfig, type RecentlySuggested, type RenderMode, SCROLL_ARROW_ATTR, SCROLL_ARROW_BOTTOM_VAR, SCROLL_ARROW_CLASS, SCROLL_ARROW_LABEL, SCROLL_ARROW_VISIBLE_ATTR, SKIPPED_PARAM_TEXT, type ScrollArrowArgs, type ScrollArrowController, type Segment, type SkippedParamState, type Store, type Suggestion, type SuggestionOption, type TaskKind, WEEKDAY_LABELS, addMonths, attachScrollArrow, buildAttributionUrl, buildDateOptions, buildQuery, buildSubmitResult, cellDay, cellIso, computeOptionsGridLayout, createStore, cursorIsAtEnd, extractPlainText, formatDate, getCursorOffset, getFooterHint, identifiedParamLabel, isOptionsGridMobileViewport, isoDate, measureOptionsGrid, monthLabel, needsOptionsGridMeasurement, optionEnterDelayMs, optionsEntranceDurationMs, optionsGridTemplateColumns, parseDate, parseLooseDate, plainTextLength, planOptionsGrid, previousGraphemeBoundary, renderEditableContent, resolveFormatType, resolveIdentifiedDate, scrollCaretIntoView, selectedIsoFromText, setCursorOffset, withSkippedParams };