@magicx-eng/ai-autocomplete-vanilla 0.14.0 → 0.15.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,14 +14,14 @@ 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
27
  - **SSR-safe** — no top-level `document`/`window` access
@@ -81,7 +81,7 @@ const ac = new AIAutocomplete(container, {
81
81
  // Appearance
82
82
  mode: "auto", // "light" | "dark" | "auto"
83
83
  optionsPosition: "below", // "above" | "below"
84
- animations: true, // enable streak + shimmer
84
+ animations: true, // press, shimmer, typed placeholder, staggered options, scroll arrow
85
85
  pillPlacement: "dropdown", // "inline" | "dropdown" | "hidden"
86
86
  dropdownTrigger: "auto", // "auto" | "manual" | "hidden"
87
87
  closeDropdownOnBlur: true, // false = keep dropdown open even when input loses focus
@@ -287,13 +287,24 @@ the calendar for you; in Tier 3 the same data reaches your own renderer (see
287
287
  | Month arrows | Page the calendar. Paging never commits anything |
288
288
  | <kbd>→</kbd> at the end of the input | Skips the parameter, same as any other pill |
289
289
 
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,
290
+ **The committed value** is written the way a person would write it, always in
291
+ English regardless of the visitor's locale, so the value you receive has one
292
+ vocabulary everywhere:
293
+
294
+ - a date inside the next week (today included) commits as its weekday name —
295
+ `Tuesday`. Like the sentence it sits in, that name is relative: read later,
296
+ it means the next such day;
297
+ - one further out in the current year as `MONTH DAY` — `March 23`;
298
+ - one in another year as `MONTH DAY YEAR` — `March 23 2027`.
299
+
300
+ It arrives as an ordinary completed parameter: bold in the input,
293
301
  and present in `completed_params` on submit like any other answer.
294
302
 
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.
303
+ **Re-editing** a committed date re-opens the calendar on the month the committed
304
+ text names, with that day marked, so changing an answer takes one click. For a
305
+ weekday name that reading is relative, so once the day it named has gone by,
306
+ re-editing marks the next such day rather than the one originally picked — the
307
+ calendar shows what the sentence says today, which is also what gets submitted.
297
308
 
298
309
  **Reading the value back.** The date arrives as text, so if you need a `Date`
299
310
  object, `parseDate` is exported for it:
@@ -301,7 +312,9 @@ object, `parseDate` is exported for it:
301
312
  ```ts
302
313
  import { parseDate } from "@magicx-eng/ai-autocomplete-vanilla";
303
314
 
304
- const due = parseDate("March 23 2026"); // Date, or null if the text isn't one of ours
315
+ // Reads all three committed shapes: a weekday name is the one date it can
316
+ // mean in the next seven days, and a yearless month-day is the current year.
317
+ const due = parseDate("March 23"); // Date, or null if the text isn't one of ours
305
318
  ```
306
319
 
307
320
  `parseLooseDate` is exported too, for the looser shapes a person types into a
@@ -362,6 +375,8 @@ The [product strip](#product-strip) works here too: pass `products` / `onProduct
362
375
 
363
376
  > 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
377
 
378
+ 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.
379
+
365
380
  ---
366
381
 
367
382
  ## Tier 3: Headless
@@ -412,7 +427,7 @@ unsub();
412
427
  > **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
428
 
414
429
 
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`.
430
+ > **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
431
  ### State shape (`CoreState`)
417
432
 
418
433
  | Field | Type | Description |
@@ -421,7 +436,7 @@ unsub();
421
436
  | `completedParams` | `CompletedParamState[]` | Filled parameters |
422
437
  | `suggestions` | `Suggestion[]` | All suggestions from server (including placeholder type) |
423
438
  | `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`. |
439
+ | `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
440
  | `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
441
  | `dateView` | `DateMonthView \| null` | The month the calendar is showing (`{ year, month }`, 0-based month). Null unless `activeFormatType` is `"date"`. |
427
442
  | `segments` | `Segment[]` | Input text split into typed text vs completed params — completed segments render as bold `<strong>` runs inside the editor |
@@ -430,7 +445,7 @@ unsub();
430
445
  | `isDropdownOpen` | `boolean` | Whether the dropdown should be visible |
431
446
  | `newParamId` | `string \| null` | ID of the most recently added param (for shimmer animation) |
432
447
  | `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. |
448
+ | `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
449
  | `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
450
  | `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
451
  | `isReady` | `boolean` | Server indicates query is complete |
@@ -470,6 +485,30 @@ The pattern for any framework:
470
485
  4. Forward user events (input, keydown, click) → core actions
471
486
  5. Destroy on unmount
472
487
 
488
+ ### Keeping the caret visible
489
+
490
+ Tier 1 handles this for you. In Tiers 2 and 3 you own the editor, so if you
491
+ render your own `contenteditable` that can scroll — held to one line with
492
+ `overflow-x`, or capped in height — call `scrollCaretIntoView` after you move
493
+ the caret yourself:
494
+
495
+ ```ts
496
+ import { scrollCaretIntoView } from "@magicx-eng/ai-autocomplete-vanilla";
497
+
498
+ // ...right after placing the caret
499
+ scrollCaretIntoView(editorElement);
500
+ ```
501
+
502
+ A browser scrolls an editor to the caret while the user types, but not when the
503
+ caret is moved by script — so answering a parameter can otherwise leave the
504
+ caret, and everything typed next, outside the visible box.
505
+
506
+ It scrolls only the nearest scrollable box around the element you pass, and
507
+ never the page. It is a no-op when nothing overflows, when the caret is already
508
+ visible, and when the caret is not inside the element you pass — which includes
509
+ a plain `<input>` or `<textarea>`, whose caret the document selection cannot
510
+ reach. Those are the browser's to scroll, not this helper's.
511
+
473
512
  ---
474
513
 
475
514
  ## API Reference
@@ -582,13 +621,17 @@ Override these on the container element. All built-in defaults use `:where()` (z
582
621
  | `--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
622
  | `--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
623
  | `--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. |
624
+ | `--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
625
  | `--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
626
  | `--aia-product-gap` | `8px` | `8px` | Gap between product cards. |
589
627
  | `--aia-product-bg` | `transparent` | `transparent` | Product card background. |
590
628
  | `--aia-product-bg-active` | `--aia-option-bg` | `--aia-option-bg` | Product card background on hover. |
591
629
  | `--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. |
630
+ | `--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. |
631
+ | `--aia-scroll-arrow-color` | `--aia-option-color` | `--aia-option-color` | Chevron color of the arrow (`--aia-scroll-arrow-color-hover` on hover). |
632
+ | `--aia-scroll-arrow-border` | `--aia-dropdown-border` | `--aia-dropdown-border` | Hairline around the arrow. |
633
+ | `--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. |
634
+ | `--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
635
  | `--aia-product-placeholder-color` | `--aia-option-color` | `--aia-option-color` | Glyph color of the no-image placeholder tile. |
593
636
  | `--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
637
  | `--aia-product-price-color` | `--aia-option-color-selected` | `--aia-option-color-selected` | Product price text. |
@@ -599,9 +642,10 @@ Override these on the container element. All built-in defaults use `:where()` (z
599
642
  | `--aia-product-price-font-size` | `11px` | `11px` | Product price font size. |
600
643
  | `--aia-product-vendor-font-size` | `10px` | `10px` | Product vendor font size. |
601
644
  | `--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. |
645
+ | `--aia-date-panel-width` | `312px` | `312px` | Max width of the whole dropdown panel while the calendar is up the panel narrows to calendar size. |
646
+ | `--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. |
647
+ | `--aia-date-row-height` | `28px` | `28px` | Height of one week row. Lower it to fit a 6-week month in a shorter dropdown. |
648
+ | `--aia-date-cell-font-size` | `13px` | `13px` | Day-number font size. |
605
649
  | `--aia-date-month-font-size` | `14px` | `14px` | Font size of the "March 2026" header. |
606
650
  | `--aia-date-weekday-font-size` | `11px` | `11px` | Font size of the S/M/T/W/T/F/S column letters. |
607
651
  | `--aia-date-today-ring` | `--aia-option-color` | `--aia-option-color` | Outline drawn around today's date. |
@@ -641,6 +685,7 @@ For styling beyond the CSS variables, target these stable `data-aia-*` attribute
641
685
  | `[data-aia-pill-scroll]` | Scrollable pill region inside the bar — carries the horizontal scroll and right-edge fade mask |
642
686
  | `[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
687
  | `[data-aia-option]` | Each suggestion option |
688
+ | `[data-aia-scroll-arrow]` | The "more below" arrow on the dropdown — carries `data-aia-visible` while shown |
644
689
  | `[data-aia-dropdown]` | The dropdown root (listbox). Carries `data-aia-has-products` while the product strip has cards. |
645
690
  | `[data-aia-datepicker]` | The calendar, rendered in place of the option list for a date parameter |
646
691
  | `[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
  */
@@ -891,6 +918,49 @@ declare function resolveIdentifiedDate(param: {
891
918
  isoDate?: unknown;
892
919
  }, opts?: LooseDateOptions): string | null;
893
920
 
921
+ /**
922
+ * Entrance choreography for the dropdown's option rows.
923
+ *
924
+ * When a new set of options lands, each row fades in while rising into place,
925
+ * and the rows start one after another from the input's side outward — the
926
+ * row nearest the input first. The reference (the RB2B support box's
927
+ * suggestion bubbles, 2026-08-19): opacity 0→1 over ~150 ms, a ~16 px rise
928
+ * easing out over ~280 ms, consecutive rows ~80–110 ms apart.
929
+ *
930
+ * The per-row motion is CSS (the option rule in each package's stylesheet —
931
+ * keep the three copies identical; the parity test compares them). This module
932
+ * owns the *timing numbers* and the per-row delay, so vanilla, React and
933
+ * Angular can't drift onto three different cascades. `OPTION_ENTER_RISE_MS` and
934
+ * `OPTION_ENTER_FADE_MS` must match the durations declared in those rules.
935
+ */
936
+ /** Gap between consecutive rows starting their entrance. */
937
+ declare const OPTION_ENTER_STAGGER_MS = 80;
938
+ /** Duration of a row's opacity fade — mirrors the stylesheets' entrance rule. */
939
+ declare const OPTION_ENTER_FADE_MS = 150;
940
+ /** Duration of a row's rise into place — mirrors the stylesheets' entrance rule. */
941
+ declare const OPTION_ENTER_RISE_MS = 280;
942
+ /** Distance a row rises from, in CSS px — mirrors the stylesheets' entrance rule. */
943
+ declare const OPTION_ENTER_RISE_PX = 16;
944
+ /** Inline custom property each option carries with its own start delay. */
945
+ declare const OPTION_ENTER_DELAY_VAR = "--aia-option-enter-delay";
946
+ /**
947
+ * Start delay (ms) for the option at `index` in a grid of `count` options laid
948
+ * out in `cols` columns, when the dropdown sits `optionsPosition` the input.
949
+ *
950
+ * Rows, not cells, are the unit: the cells of one row share a start so a
951
+ * two-column layout reads as rows appearing, not as a zig-zag. "below" starts
952
+ * at the top row (the one against the input); "above" starts at the bottom row
953
+ * for the same reason — the stack is reversed there and the last row is the
954
+ * one nearest the input.
955
+ */
956
+ declare function optionEnterDelayMs(index: number, cols: number, count: number, optionsPosition?: "above" | "below"): number;
957
+ /**
958
+ * How long after the options render until the last row has settled — the
959
+ * moment anything that measures the grid (the scroll arrow) can trust its
960
+ * geometry again, since a row mid-rise still extends the scrollable area.
961
+ */
962
+ declare function optionsEntranceDurationMs(count: number, cols: number): number;
963
+
894
964
  /**
895
965
  * Rows/columns policy for the dropdown's options grid.
896
966
  *
@@ -912,11 +982,61 @@ interface OptionsGridLayout {
912
982
  }
913
983
  /**
914
984
  * - 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).
985
+ * - Web: one column, four rows visible (scroll past four).
986
+ *
987
+ * This is the measurement-free baseline (and the only layout mobile ever
988
+ * uses). On web, `planOptionsGrid` upgrades five-plus options to two columns
989
+ * when the rendered rows prove they fit — the upgrade needs the DOM, so it
990
+ * can't live here.
918
991
  */
919
992
  declare function computeOptionsGridLayout(count: number, isMobile: boolean): OptionsGridLayout;
993
+ interface OptionsGridPlan extends OptionsGridLayout {
994
+ /**
995
+ * Value for `grid-template-columns`. Tracks carry no inner spaces (the
996
+ * keyboard controller counts tracks by splitting on spaces).
997
+ */
998
+ template: string;
999
+ /**
1000
+ * Indices of the options on the last visible row of a scrollable web grid —
1001
+ * their text gets the fade that signals more options below. Empty on mobile
1002
+ * and whenever everything is already visible.
1003
+ */
1004
+ scrollHintIndices: number[];
1005
+ }
1006
+ /**
1007
+ * Whether measuring the rendered rows could change the plan at all. Measuring
1008
+ * clones every row and forces a layout, and renders happen per keystroke —
1009
+ * so the callers skip it whenever `planOptionsGrid` would ignore the result
1010
+ * anyway (mobile, or fewer options than the two-column minimum).
1011
+ */
1012
+ declare function needsOptionsGridMeasurement(count: number, isMobile: boolean): boolean;
1013
+ /**
1014
+ * The full layout decision, measurements included.
1015
+ *
1016
+ * Web, five-plus options: two columns of three visible rows — but only when
1017
+ * every option provably fits on one line. Rows fill row-major (even indices
1018
+ * left, odd right), each column is as wide as its widest option, and the
1019
+ * columns may be unequal: the template splits the width in proportion to the
1020
+ * two column maxima, so whenever `left + right <= gridWidth`, each column gets
1021
+ * at least what its widest row needs. When the pair doesn't fit — or there are
1022
+ * no usable measurements (SSR, hidden grid, loading skeletons) — the layout
1023
+ * stays one scrollable column.
1024
+ *
1025
+ * `rowWidths` are single-line pixel widths of the rendered rows (see
1026
+ * `measureOptionsGrid`); `gridWidth` is the grid's content width.
1027
+ */
1028
+ declare function planOptionsGrid(count: number, isMobile: boolean, rowWidths: number[] | null, gridWidth: number | null): OptionsGridPlan;
1029
+ /**
1030
+ * Measure the rendered option rows for `planOptionsGrid`: each row's
1031
+ * single-line width (a hidden `max-content` + `nowrap` clone, so real classes,
1032
+ * fonts, icons, tags and padding are all in the number), and the width the
1033
+ * columns have to share — the grid's content box less its column gap.
1034
+ * Returns null when there's nothing to measure.
1035
+ */
1036
+ declare function measureOptionsGrid(grid: HTMLElement): {
1037
+ rowWidths: number[];
1038
+ gridWidth: number;
1039
+ } | null;
920
1040
  /**
921
1041
  * `grid-template-columns` for a fixed column count. Space-separated
922
1042
  * `minmax(0,1fr)` tracks (no `repeat()`, no inner spaces) so rows fill
@@ -952,6 +1072,10 @@ declare function getCursorOffset(root: HTMLElement): number | null;
952
1072
  * with no following text node, we use `setStartAfter(strong)` so the caret
953
1073
  * sits OUTSIDE the bold subtree — otherwise a caret at the end of a strong's
954
1074
  * text is still "inside" the strong, which would falsely trigger re-edit mode.
1075
+ *
1076
+ * Also scrolls the caret back into view (see `scrollCaretIntoView`) — this is
1077
+ * the single caret-placement choke point, and a scripted caret move does not
1078
+ * get the browser's own scroll-to-caret behaviour.
955
1079
  */
956
1080
  declare function setCursorOffset(root: HTMLElement, offset: number): void;
957
1081
  /**
@@ -966,6 +1090,66 @@ declare function cursorIsAtEnd(root: HTMLElement): boolean;
966
1090
  */
967
1091
  declare function previousGraphemeBoundary(text: string, offset: number): number;
968
1092
 
1093
+ /**
1094
+ * Scroll-to-caret for the editor's own scroll container.
1095
+ *
1096
+ * The editor is a scroll container (`overflow-y: auto` under a height cap in
1097
+ * the core stylesheet; a consumer holding it to one line adds `overflow-x`).
1098
+ * Browsers scroll such a box to the caret as the user types, but not when the
1099
+ * caret is moved by script — which the SDK does on every render that actually
1100
+ * rewrites the editable's content (`renderEditable` early-returns on an
1101
+ * unchanged segment key, and restores the caret only while focused).
1102
+ * Everything here exists to close that gap.
1103
+ */
1104
+ /**
1105
+ * Scroll the caret back into view inside the editor's own scroll container.
1106
+ *
1107
+ * Browsers do this themselves while the user types, but not when the caret is
1108
+ * moved by script — so every programmatic caret placement (option selection,
1109
+ * caret restore after a re-render, controlled-mode `setValue`) would otherwise
1110
+ * leave the caret, and everything typed next, outside the visible box.
1111
+ *
1112
+ * Exactly one box is scrolled, by writing its own `scrollLeft` / `scrollTop`:
1113
+ * the nearest scrollable ancestor of the editable, which inside the widget's
1114
+ * own markup is the editor. `scrollIntoView` is deliberately not used — it
1115
+ * would walk the whole ancestor chain and the page with it. When the widget
1116
+ * does not own the editable's ancestry (a consumer-rendered editor in
1117
+ * `"dropdown"` / `"headless"` mode) that nearest box may be one of the
1118
+ * consumer's, which is what the browser would scroll for typed input anyway;
1119
+ * nothing above it moves either way. A no-op when nothing overflows, when the
1120
+ * caret is already visible, or when the caret has no measurable box.
1121
+ */
1122
+ declare function scrollCaretIntoView(root: HTMLElement): void;
1123
+
1124
+ /**
1125
+ * Types the starting-state placeholder into the input one character at a
1126
+ * time instead of popping it in whole.
1127
+ *
1128
+ * The reference (the RB2B support box, 2026-08-19): ~33 ms per character, no
1129
+ * caret, a brief hold on word boundaries; when the phrase changes the old
1130
+ * one fades out (~120 ms), the field sits empty for a beat (~50 ms), then the
1131
+ * new phrase types in from nothing. Characters appear at full opacity — the
1132
+ * only fade is the outgoing phrase.
1133
+ *
1134
+ * Shared by every package: vanilla, React and Angular all render the
1135
+ * placeholder through `renderEditableContent`, which hands the target text
1136
+ * to `syncPlaceholder` below. The typed prefix lives in `data-placeholder`
1137
+ * (the stylesheets render it via `::before`), so nothing enters the
1138
+ * contentEditable's DOM and the core's notion of the placeholder — which
1139
+ * drives Tab-to-accept and prefix-typing suppression — stays the full phrase
1140
+ * at all times.
1141
+ */
1142
+ /** Delay between characters. */
1143
+ declare const PLACEHOLDER_TYPE_MS = 33;
1144
+ /** Extra hold after a word boundary (space / hyphen). */
1145
+ declare const PLACEHOLDER_WORD_PAUSE_MS = 70;
1146
+ /** Fade-out of the outgoing phrase — must match `--aia-placeholder-fade` in the stylesheets. */
1147
+ declare const PLACEHOLDER_FADE_OUT_MS = 120;
1148
+ /** Empty beat between the fade-out and the next phrase typing in. */
1149
+ declare const PLACEHOLDER_SWAP_GAP_MS = 50;
1150
+ /** Set on the input while the outgoing phrase fades; the stylesheets fade `::before` on it. */
1151
+ declare const PLACEHOLDER_LEAVING_ATTR = "data-aia-placeholder-leaving";
1152
+
969
1153
  interface RenderEditableArgs {
970
1154
  input: HTMLElement;
971
1155
  segments: Segment[];
@@ -990,6 +1174,56 @@ interface RenderEditableArgs {
990
1174
  */
991
1175
  declare function renderEditableContent(args: RenderEditableArgs): void;
992
1176
 
1177
+ /**
1178
+ * The "more below" arrow for the options grid.
1179
+ *
1180
+ * When the option list is taller than its scroll box, a small round button
1181
+ * with a down chevron sits at the bottom-centre of the list. It slides up into
1182
+ * view from behind the dropdown's lower edge (the footer, when the dropdown
1183
+ * opens below the input) the moment there is more to scroll to, slides back
1184
+ * down once the list is scrolled to its end, and scrolls the list a page on
1185
+ * click. The reference (the RB2B support panel, 2026-08-19): a white 32 px
1186
+ * disc that rises from behind the composer over ~150–180 ms, no fade.
1187
+ *
1188
+ * The DOM differs per package (vanilla builds the button here; React and
1189
+ * Angular render their own markup with the same classes and data attributes),
1190
+ * so the *behaviour* lives in this one controller and each package hands it
1191
+ * its dropdown, grid and button elements:
1192
+ *
1193
+ * - `update()` after every render — re-measures where the grid's bottom edge
1194
+ * is and re-evaluates visibility. The disc seats itself in the grid's
1195
+ * reserved fade band when there is one (below-mode with the footer), and
1196
+ * floats a small gap above the grid's edge in layouts that zero the band
1197
+ * (`optionsPosition="above"`, product strip). A new
1198
+ * option group (`data-aia-group` on the grid changed) keeps the arrow hidden
1199
+ * until the rows' entrance animation has settled, because a row mid-rise
1200
+ * still extends the scrollable area and would flash the arrow on.
1201
+ * - The controller listens for the grid's own `scroll` and size changes itself.
1202
+ * - `destroy()` when the grid or dropdown goes away.
1203
+ */
1204
+ declare const SCROLL_ARROW_CLASS = "magicx-aia-scroll-arrow";
1205
+ /** Stable styling hook (module class names aren't part of the API). */
1206
+ declare const SCROLL_ARROW_ATTR = "data-aia-scroll-arrow";
1207
+ /** Present on the button while it is shown. The stylesheets slide it in on this. */
1208
+ declare const SCROLL_ARROW_VISIBLE_ATTR = "data-aia-visible";
1209
+ /** Inline custom property: how far the button's bottom edge sits above the dropdown's padding edge. */
1210
+ declare const SCROLL_ARROW_BOTTOM_VAR = "--aia-scroll-arrow-bottom";
1211
+ declare const SCROLL_ARROW_LABEL = "Scroll down for more options";
1212
+ interface ScrollArrowController {
1213
+ /** Re-measure and re-evaluate. Call after every render of the dropdown. */
1214
+ update(): void;
1215
+ destroy(): void;
1216
+ }
1217
+ interface ScrollArrowArgs {
1218
+ /** The dropdown panel — the button's containing block; its lower edge clips the hidden button. */
1219
+ dropdown: HTMLElement;
1220
+ /** The options grid (the scroll box). */
1221
+ grid: HTMLElement;
1222
+ /** The arrow button, already in the dropdown. */
1223
+ button: HTMLElement;
1224
+ }
1225
+ declare function attachScrollArrow({ dropdown, grid, button, }: ScrollArrowArgs): ScrollArrowController;
1226
+
993
1227
  type Listener<S> = (next: S, prev: S) => void;
994
1228
  interface Store<S> {
995
1229
  get: () => S;
@@ -1060,6 +1294,23 @@ declare class ModeController {
1060
1294
  private detachListener;
1061
1295
  }
1062
1296
 
1297
+ /**
1298
+ * The dropdown pill-bar label for a parameter that has no suggestion behind it.
1299
+ *
1300
+ * Every pill normally shows its suggestion's `text` — a phrase written to be
1301
+ * read, "Due date". A date the server identified in the user's own words
1302
+ * ("september 5th") has no suggestion to borrow one from, only the wire `type`,
1303
+ * so the type is read back into words instead: `travel_date` and `travelDate`
1304
+ * both become "travel date". Without this the raw type is the label, both while
1305
+ * the calendar is open over the span and again if that parameter is re-edited
1306
+ * after being answered.
1307
+ *
1308
+ * Lower-casing costs nothing here — every package's pill CSS sets
1309
+ * `text-transform: uppercase`, so a label's original case never reaches the
1310
+ * screen either way.
1311
+ */
1312
+ declare function identifiedParamLabel(type: string): string;
1313
+
1063
1314
  /**
1064
1315
  * Sentinel `text` marking a `completed_params` entry the user skipped (→)
1065
1316
  * rather than filled. Sent regardless of `maskCompletedText` — it's a fixed
@@ -1092,4 +1343,4 @@ declare function withSkippedParams(completed: CompletedParam[], skipped: Skipped
1092
1343
  */
1093
1344
  declare function buildSubmitResult(text: string, completedParams: CompletedParamState[], skippedParams?: SkippedParamState[]): AutocompleteResult;
1094
1345
 
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 };
1346
+ 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 };