@magicx-eng/ai-autocomplete-vanilla 0.17.0 → 0.19.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,8 +14,8 @@ 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 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
- - **Option overrides** — inject or dynamically generate client-side options per suggestion type
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. A parameter whose options are written as spans (`September 11 - October 13`) gets the same calendar answered in two clicks, committed as one range.
18
+ - **Option overrides** — supply the options for a parameter yourself: a fixed list, a computed one, or one fetched from your own search endpoint as the user types
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`
@@ -74,7 +74,7 @@ const ac = new AIAutocomplete(container, {
74
74
 
75
75
  // API
76
76
  apiConfig: { apiKey: "...", authScheme: "Bearer", endpoint: "https://api.ai-autocomplete.com/api/suggest" },
77
- optionOverrides: { account: (query) => [...] },
77
+ optionOverrides: { location: async (query, signal) => [...] }, // see "Option Overrides"
78
78
  columns: 2,
79
79
  maskCompletedText: false, // when true, omits completed params' text from API requests (PII masking)
80
80
  additionalContext: { tier: "gold" }, // optional user context, to personalize suggestions and options
@@ -344,6 +344,25 @@ say which, and phrases like `next friday`. Those still open the calendar, just
344
344
  on the current month with nothing marked, so the user picks rather than being
345
345
  shown a guess that might be wrong.
346
346
 
347
+ **Date ranges.** A parameter whose options are written as spans — `September 11
348
+ - October 13`, `September 11 to November 16` — is answered by the same calendar
349
+ in two clicks: the first sets the start, the second the end, and the pair
350
+ commits as one answer. The days between them band as the user moves across the
351
+ calendar, so the span being chosen is visible before it is committed. Paging the
352
+ month between the two clicks is expected — that is how a span crosses months —
353
+ and <kbd>Esc</kbd> drops the start and lets them begin again. Clicking the end
354
+ before the start is fine; the two are ordered on the way in.
355
+
356
+ The span is read off the options the same way a single date is read off its own:
357
+ either separator (`-`, `–`, `—`, `to`, `through`, `until`), either half in any
358
+ date format the SDK reads, and a name that says so (`date_range`, `dateRange`)
359
+ counts too. It commits as one parameter, written the way it was offered —
360
+ `September 11 - October 13`, with the year on whichever end needs it — and both
361
+ ends are also recorded on the completed parameter's `metadata` as `YYYY-MM-DD`,
362
+ under the `DATE_RANGE_META_START` / `DATE_RANGE_META_END` keys this package
363
+ exports (`aiaDateRangeStart` / `aiaDateRangeEnd`), so you never have to parse
364
+ the prose. Re-editing one re-opens the calendar with both ends marked.
365
+
347
366
  **Typing** while the calendar is open does not filter it. The text is treated as
348
367
  a new query, so suggestions refresh as the user types — useful when someone
349
368
  would rather describe what they want than pick a day.
@@ -435,7 +454,7 @@ unsub();
435
454
  > **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.
436
455
 
437
456
 
438
- > **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`.
457
+ > **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`. For a **range** parameter `activeFormatType` is `"date-range"` and the same `selectOption(cell)` is called twice: the first records the start (`state.dateRangeStart` holds it, and nothing is committed yet), the second commits the span. `dateCellMarks` and `visibleDateRange` are exported to paint the two ends and the band between them the way the built-in calendars do.
439
458
  ### State shape (`CoreState`)
440
459
 
441
460
  | Field | Type | Description |
@@ -445,14 +464,17 @@ unsub();
445
464
  | `suggestions` | `Suggestion[]` | All suggestions from server (including placeholder type) |
446
465
  | `actionableSuggestions` | `Suggestion[]` | Non-placeholder suggestions (the pills) |
447
466
  | `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`. |
448
- | `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. |
449
- | `dateView` | `DateMonthView \| null` | The month the calendar is showing (`{ year, month }`, 0-based month). Null unless `activeFormatType` is `"date"`. |
467
+ | `activeFormatType` | `"options" \| "date" \| "date-range"` | How the active parameter is answered: pick an option, pick a date, or pick a span of dates. Both date formats show a calendar instead of the option list; `"date-range"` takes two clicks, a start and an end. Typing does not filter it — the text is treated as a new query. |
468
+ | `dateView` | `DateMonthView \| null` | The month the calendar is showing (`{ year, month }`, 0-based month). Null unless `activeFormatType` is a calendar one. |
469
+ | `dateRangeStart` | `string \| null` | While a range is half-picked, the start already chosen, as `YYYY-MM-DD`. Null at every other moment, including for every non-range parameter. |
450
470
  | `segments` | `Segment[]` | Input text split into typed text vs completed params — completed segments render as bold `<strong>` runs inside the editor |
451
471
  | `placeholderText` | `string` | Placeholder text from server suggestions (joined `placeholder`-type suggestion texts) |
452
472
  | `activeDropdownIndex` | `number` | Highlighted option index (-1 = none) |
453
473
  | `isDropdownOpen` | `boolean` | Whether the dropdown should be visible |
454
474
  | `newParamId` | `string \| null` | ID of the most recently added param (for shimmer animation) |
455
475
  | `isLoading` | `boolean` | Fetch in progress. The Tier 1 / Tier 2 renderers gate the loading skeleton additionally on `!inSelectionAnimation` and `!editingParam`. |
476
+ | `optionQuery` | `string` | The phrase the active parameter's options are filtered by — what the user has typed for it, trimmed — or the replacement typed so far during a re-edit. This is what an [option override](#option-overrides) is asked with. |
477
+ | `isSearchingOptions` | `boolean` | True while an [option override](#option-overrides) for the parameter on screen has been asked and hasn't answered. Render the same skeleton you render for `isLoading`; the built-in dropdowns do. |
456
478
  | `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. |
457
479
  | `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. |
458
480
  | `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`. |
@@ -620,7 +642,7 @@ Events: `submit`, `result`, `error`, `change`, `paramsChange`, `stateChange`, `f
620
642
 
621
643
  `result` carries an `AutocompleteResult` after every successful round-trip — see [Reading the query as it's built](#reading-the-query-as-its-built). Prefer it over `stateChange` when what you want is the structured query: `stateChange` fires on every internal update (focus, highlight, keystroke) and hands you raw state you would have to assemble yourself.
622
644
 
623
- **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.
645
+ **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 or rejects is logged once per instance and treated as an empty answer, never as a fetch error. 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.
624
646
 
625
647
  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.
626
648
 
@@ -708,6 +730,7 @@ Override these on the container element. All built-in defaults use `:where()` (z
708
730
  | `--aia-date-weekday-font-size` | `11px` | `11px` | Font size of the S/M/T/W/T/F/S column letters. |
709
731
  | `--aia-date-today-ring` | `--aia-option-color` | `--aia-option-color` | Outline drawn around today's date. |
710
732
  | `--aia-date-selected-bg` | white at 12% | white at 12% | Fill behind the date a re-edited parameter already holds. |
733
+ | `--aia-date-range-bg` | white at 6% | white at 6% | Band across the days between the two ends of a picked date range. The ends themselves use `--aia-date-selected-bg`. |
711
734
  | `--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. |
712
735
 
713
736
  ### Per-mode Overrides
@@ -806,24 +829,36 @@ const ac = new AIAutocomplete(container, {
806
829
 
807
830
  ## Option Overrides
808
831
 
832
+ Supply the options for a parameter yourself instead of taking the server's. Each entry is keyed by the suggestion `type` and is a function of the phrase the user has typed for that parameter:
833
+
809
834
  ```ts
810
835
  new AIAutocomplete(el, {
811
836
  optionOverrides: {
837
+ // A fixed list — a plain array is applied at once, no loading state.
812
838
  account: () => [
813
839
  { text: "Savings", is_tappable: true, kind: null },
814
840
  { text: "Checking", is_tappable: true, kind: null },
815
841
  ],
816
- value: (query) => {
817
- const digits = query.replace(/\D/g, "");
818
- if (!digits) return [{ text: "$100", is_tappable: true, kind: null }];
819
- return [{ text: `$${digits}`, is_tappable: true, kind: null }];
842
+ // A list that lives behind a request — return a promise. The dropdown
843
+ // shows its loading skeleton until it settles.
844
+ location: async (query, signal) => {
845
+ const res = await fetch(`/api/locations?q=${encodeURIComponent(query)}`, { signal });
846
+ const places = await res.json();
847
+ return places.map((p) => ({ text: p.name, is_tappable: true, kind: null }));
820
848
  },
821
849
  },
822
- onSubmit: handleSubmit,
823
850
  });
824
851
  ```
825
852
 
826
- ---
853
+ **When it's called.** Once the moment the parameter becomes active — a response suggests it, a skip or a selection moves it to the front, or the user taps a completed value of that type to change it — with whatever they have already typed for it (usually `""`, the request for the default list). Then again on the SDK's typing debounce with each new phrase, so you can run a search or page through a larger list. If a phrase is already covered by what you last returned, return that list again. The third argument is the `Suggestion` being answered, for the rare override that serves more than one type.
854
+
855
+ **What's shown.** Your answer, as-is: it is not filtered again by the phrase it was produced for, so a fuzzy or synonym match survives. Between two calls the previous answer is filtered locally by what the user types, so the list reacts to every keystroke. Typing the full text of an option in the answer completes the parameter, exactly as it does for a server option.
856
+
857
+ **The server's role.** The server's own options for an overridden type are never shown. While an override owns the active parameter the server is not asked for suggestions; it is asked again when the parameter is answered or skipped. Return an empty list for a typed phrase and the SDK falls back to the server for that phrase — the typed text goes out the way it does for a parameter with no matching options — and won't ask you the same phrase twice. An empty list for `""` leaves the parameter on screen with no options.
858
+
859
+ **Cancellation and errors.** Honour `signal`: it is aborted when a newer phrase supersedes the call, when the parameter stops being active, and on `destroy()`. A throw or a rejection is logged once per instance and treated as an empty answer, never as a fetch error.
860
+
861
+ Tier 3 exposes the pieces: `state.optionQuery` is the phrase, and `state.isSearchingOptions` is true while an answer is pending.
827
862
 
828
863
  ## License
829
864
 
package/dist/index.d.mts CHANGED
@@ -147,7 +147,42 @@ interface AccessTokenResult {
147
147
  expiresAt?: number;
148
148
  }
149
149
  type APIConfig = APIKeyConfig | AccessTokenConfig;
150
- type OptionOverrides = Record<string, (query: string) => SuggestionOption[]>;
150
+ /**
151
+ * Supplies the options for one suggestion type in place of the server's,
152
+ * which are never shown for an overridden type.
153
+ *
154
+ * Called
155
+ * - the moment a pill of this type becomes active (a response suggests it, a
156
+ * skip or a selection moves it to the front, a completed param of this type
157
+ * is tapped to re-edit), with whatever the user has already typed for it —
158
+ * usually `""`, the request for the default list;
159
+ * - again on the SDK's typing debounce with each new phrase, so a consumer
160
+ * whose options live behind a request can run a second search or page
161
+ * through a larger list. Return the same list early if the phrase is
162
+ * already covered by what was last returned.
163
+ *
164
+ * A plain array is applied synchronously — the right shape for a fixed or
165
+ * locally computed list. A promise shows the dropdown's loading state until it
166
+ * settles. Either way the answer is listed as-is, never filtered again by the
167
+ * phrase it was produced for, so a fuzzy or synonym match survives. Between
168
+ * two calls the previous answer is filtered locally by what the user types,
169
+ * for instant feedback.
170
+ *
171
+ * The server is not asked for suggestions while an override owns the active
172
+ * pill. It is asked again when the user answers or skips the pill, and as a
173
+ * fallback when the override returns an empty list for a non-empty phrase —
174
+ * the typed text then goes to the server the way it does for a pill with no
175
+ * matching options. An empty answer for `""` leaves the pill with no options,
176
+ * and the override is not re-asked for a phrase it has already answered empty.
177
+ *
178
+ * Honour `signal` in anything asynchronous: it is aborted when a newer phrase
179
+ * supersedes this call, when the pill stops being active, and on `destroy()`.
180
+ * A rejected promise or a throw is contained — logged once per instance and
181
+ * treated as an empty answer.
182
+ */
183
+ type OptionOverride = (query: string, signal: AbortSignal, suggestion: Suggestion) => Promise<SuggestionOption[]> | SuggestionOption[];
184
+ /** Per-suggestion-type option overrides, keyed by the suggestion's `type`. */
185
+ type OptionOverrides = Record<string, OptionOverride>;
151
186
  /**
152
187
  * A single product card in the dropdown's product strip.
153
188
  *
@@ -264,6 +299,17 @@ declare function isoDate(d: Date): string;
264
299
  * - a different year: "March 23 2027".
265
300
  */
266
301
  declare function formatDate(d: Date): string;
302
+ /**
303
+ * A date written as month and day, with the year only when it isn't this one —
304
+ * {@link formatDate} without the weekday shorthand.
305
+ *
306
+ * The shorthand is what a range must not use. "Tuesday - Friday" names four
307
+ * possible spans depending on when it is read, and the two ends of a range are
308
+ * read against each other rather than against today, so the one thing the
309
+ * weekday form is good at — naming a day inside this week unambiguously —
310
+ * stops being true the moment it is half of a span. See `formatDateRange`.
311
+ */
312
+ declare function formatAbsoluteDate(d: Date): string;
267
313
  /**
268
314
  * Inverse of {@link formatDate}, for re-opening the calendar on the month a
269
315
  * completed date param already holds. Reads all three committed shapes — a
@@ -326,21 +372,29 @@ declare function cellDay(option: SuggestionOption | undefined): number | null;
326
372
  *
327
373
  * `"options"` is every suggestion the server has ever sent: pick one of its
328
374
  * options, or type to filter them. `"date"` ignores the options entirely and
329
- * renders a calendar instead.
375
+ * renders a calendar instead. `"date-range"` renders the same calendar,
376
+ * answered in two taps — a start and an end — and committed as one span.
330
377
  */
331
- type FormatType = "options" | "date";
378
+ type FormatType = "options" | "date" | "date-range";
379
+ /** Whether this format is answered by a calendar rather than by a list. */
380
+ declare function isCalendarFormat(format: FormatType): boolean;
332
381
  /**
333
382
  * Decide how a suggestion should be answered.
334
383
  *
335
- * The server cannot send a `formatType` yet, so a suggestion whose `type` names
336
- * a date is detected here by its name and rendered as a calendar with its
337
- * options ignored.
384
+ * The server cannot send a `formatType` yet, so a suggestion that holds a date
385
+ * is detected here by the options it offers, and failing that by its name
386
+ * and rendered as a calendar with those options ignored.
338
387
  *
339
- * That name check is deliberately the LAST resort. A `formatType` that *is* on
388
+ * The detection is deliberately the LAST resort. A `formatType` that *is* on
340
389
  * the wire always wins, so the day the server starts sending one this function
341
- * needs a single line deleted — the datepicker itself, and every gate that
390
+ * needs a single block deleted — the datepicker itself, and every gate that
342
391
  * consults this, keep working untouched.
343
392
  *
393
+ * Options are read before the name because they are the stronger evidence:
394
+ * they are the values themselves, while a name is what someone chose to call
395
+ * them. A parameter called `travel_date` whose options are all spans is a span
396
+ * parameter, whatever its name says.
397
+ *
344
398
  * Accepts anything with a `type`, not a `Suggestion`, so re-edit can ask the
345
399
  * same question about a completed param via its `suggestionType`.
346
400
  */
@@ -391,6 +445,20 @@ interface CoreInputState {
391
445
  * never shows results belonging to an older query.
392
446
  */
393
447
  products: Product[];
448
+ /**
449
+ * The consumer's {@link OptionSource} request for the pill on screen — the
450
+ * active suggestion, or the completed param being re-edited. `type` is the
451
+ * suggestion type it was asked for and `query` the phrase it was asked with;
452
+ * the answer itself is written into that suggestion's (or param's) `options`
453
+ * so every reader of those — filtering, exact-match promotion, the re-edit
454
+ * cache — sees it without knowing where it came from. Null when no source
455
+ * owns the pill on screen. Owned by `OptionSourceController`.
456
+ */
457
+ optionSearch: {
458
+ type: string;
459
+ query: string;
460
+ status: "loading" | "done";
461
+ } | null;
394
462
  activeDropdownIndex: number;
395
463
  newParamId: string | null;
396
464
  isLoading: boolean;
@@ -416,7 +484,7 @@ interface CoreInputState {
416
484
  /** Current caret offset within the editor. Tracked via DOM `selectionchange`. */
417
485
  caretOffset: number | null;
418
486
  /**
419
- * True for ~500ms after a user-initiated option selection so the press
487
+ * True for ~170ms after a user-initiated option selection so the press
420
488
  * animation can finish before the dropdown switches to its loading skeleton.
421
489
  * Set by selectOption / ReEditManager.selectOption, cleared by a timer.
422
490
  */
@@ -433,6 +501,25 @@ interface CoreInputState {
433
501
  dateViewMonth: (DateMonthView & {
434
502
  key: string;
435
503
  }) | null;
504
+ /**
505
+ * The first end of a range the user is part-way through picking, as
506
+ * `YYYY-MM-DD`, or null when no pick is in progress. Surfaces to readers as
507
+ * the derived `dateRangeStart`, the way `dateViewMonth` surfaces as
508
+ * `dateView`.
509
+ *
510
+ * Only meaningful while `activeFormatType` is `"date-range"`, where a date is
511
+ * answered in two taps. `key` names the parameter the pick belongs to (see
512
+ * `dateViewKey`), and unlike `dateViewMonth` a stale one is not merely
513
+ * ignored — it is cleared, by the invariant in `AIAutocomplete`'s
514
+ * constructor. Scoping alone would only HIDE it, and every key it can carry
515
+ * reproduces exactly later in a session: the same pill type re-suggested, the
516
+ * same param re-edited, the same span re-tapped. A resurrected start would
517
+ * then pair with an unrelated tap and commit a span nobody chose.
518
+ */
519
+ pendingRangeStart: {
520
+ iso: string;
521
+ key: string;
522
+ } | null;
436
523
  /**
437
524
  * Set while the user is picking a date for a span the server identified as
438
525
  * one — "september 5th" in "fly to vegas on september 5th".
@@ -462,6 +549,20 @@ interface CoreInputState {
462
549
  */
463
550
  text: string;
464
551
  iso: string | null;
552
+ /**
553
+ * The other end, when the span reads as a range ("september 11 to november
554
+ * 16") rather than as one day. Null for a single date, and null for a range
555
+ * whose text couldn't be read — which costs the pre-selection only.
556
+ */
557
+ isoEnd: string | null;
558
+ /**
559
+ * How this span is answered — a calendar, or a calendar in two taps.
560
+ *
561
+ * Resolved once when the picker opens and carried here rather than
562
+ * re-derived, because it depends on the span's own text (a `travel_date`
563
+ * written as a span is a range) and the derive layer only sees its type.
564
+ */
565
+ format: FormatType;
465
566
  } | null;
466
567
  }
467
568
  /**
@@ -474,8 +575,9 @@ interface CoreDerivedState {
474
575
  actionableSuggestions: Suggestion[];
475
576
  /**
476
577
  * What the dropdown renders. Options for the active pill, filtered by what
477
- * the user typed — EXCEPT when `activeFormatType` is `"date"`, where these
478
- * are the visible month's calendar cells and the server's own options for
578
+ * the user typed — EXCEPT when `activeFormatType` is a calendar one
579
+ * (`"date"` / `"date-range"`), where these are the visible month's calendar
580
+ * cells and the server's own options for
479
581
  * that suggestion are ignored. See {@link buildDateOptions}.
480
582
  */
481
583
  filteredOptions: SuggestionOption[];
@@ -487,9 +589,30 @@ interface CoreDerivedState {
487
589
  * exact-match gates in `fetchController`).
488
590
  */
489
591
  activeFormatType: FormatType;
490
- /** The month the datepicker is showing. Null unless `activeFormatType` is `"date"`. */
592
+ /** The month the datepicker is showing. Null unless `activeFormatType` is a calendar one. */
491
593
  dateView: DateMonthView | null;
594
+ /**
595
+ * The first end of a range already picked, as `YYYY-MM-DD`, while the user is
596
+ * choosing the second. Null whenever no pick is in progress — including for
597
+ * every non-range format, so a UI can read it without checking the format
598
+ * first. The calendars paint the span between it and the highlighted cell.
599
+ */
600
+ dateRangeStart: string | null;
492
601
  placeholderText: string;
602
+ /**
603
+ * The phrase the active pill's options are filtered by — what the user has
604
+ * typed for it, trimmed — or, during a re-edit, the replacement typed so
605
+ * far. `""` when nothing has been typed for the pill yet. This is the query
606
+ * an {@link OptionSource} is asked with.
607
+ */
608
+ optionQuery: string;
609
+ /**
610
+ * True while the consumer's {@link OptionSource} for the pill on screen has
611
+ * been asked and hasn't answered yet. The built-in dropdowns show their
612
+ * loading skeleton for it exactly as they do for `isLoading`; a custom UI
613
+ * should treat it the same way. Never true for a pill without a source.
614
+ */
615
+ isSearchingOptions: boolean;
493
616
  isDropdownOpen: boolean;
494
617
  /**
495
618
  * Whether the active (leading) pill should render in its `selected` state
@@ -648,6 +771,7 @@ declare class AIAutocomplete {
648
771
  private keyboardController;
649
772
  private pillsController;
650
773
  private productsController;
774
+ private optionSource;
651
775
  private reEdit;
652
776
  private modeController;
653
777
  private container;
@@ -656,11 +780,9 @@ declare class AIAutocomplete {
656
780
  private domRefs;
657
781
  private dropdownRefs;
658
782
  private timers;
659
- /** One per instance — see {@link ConsumerBoundary}. Shared by the emitter, `subscribe()` and `optionOverrides`. */
783
+ /** One per instance — see {@link ConsumerBoundary}. Shared by the emitter and `subscribe()`. */
660
784
  private boundary;
661
785
  /** Identity of the raw override record the wrapped copy below was built from. */
662
- private rawOverrides;
663
- private wrappedOverrides;
664
786
  private subscriberCount;
665
787
  private emitter;
666
788
  private sessionId;
@@ -844,24 +966,6 @@ declare class AIAutocomplete {
844
966
  selectOption(option: SuggestionOption): void;
845
967
  private startSelectionAnimationTimer;
846
968
  private fireTelemetry;
847
- /**
848
- * `this.opts` with every `optionOverrides` entry wrapped in the instance's
849
- * {@link ConsumerBoundary}.
850
- *
851
- * The derive layer calls these functions on the SDK's stack — from
852
- * `getState()`, and from inside the store's notification drain — so an
853
- * un-wrapped throw would unwind whatever internal operation triggered the
854
- * derive and abort delivery of every queued notification with it, taking the
855
- * instance down rather than just the override. Wrapped, a failed override
856
- * answers `undefined` and each call site falls back to the server's options.
857
- *
858
- * Memoized on the raw record's identity so a swapped integration is
859
- * re-wrapped while a stable one isn't re-wrapped on every derive. Note
860
- * `update({ optionOverrides })` only becomes visible on the next store write
861
- * — the derived layer memoizes on inputs identity, and `update` doesn't
862
- * invalidate it for this key. Pre-existing, and unchanged by the wrapping.
863
- */
864
- private deriveOpts;
865
969
  private setupContainer;
866
970
  private buildAndRenderFull;
867
971
  private buildAndRenderDropdown;
@@ -974,6 +1078,149 @@ declare function resolveIdentifiedDate(param: {
974
1078
  isoDate?: unknown;
975
1079
  }, opts?: LooseDateOptions): string | null;
976
1080
 
1081
+ /**
1082
+ * A span of days, both ends inclusive, as `YYYY-MM-DD`. `start <= end` always —
1083
+ * every producer here orders the two.
1084
+ */
1085
+ interface DateRange {
1086
+ start: string;
1087
+ end: string;
1088
+ }
1089
+ /**
1090
+ * What joins the two ends of a committed range.
1091
+ *
1092
+ * A spaced hyphen, because the committed text lands verbatim in the user's own
1093
+ * sentence and is what the server parses back. `parseLooseDateRange` reads far
1094
+ * more separators than this one — it has to, since it is also what reads a
1095
+ * server's option text and the user's own words — but the SDK only ever writes
1096
+ * this one.
1097
+ */
1098
+ declare const RANGE_SEPARATOR = " - ";
1099
+ /** Metadata key holding a committed range's start, as `YYYY-MM-DD`. */
1100
+ declare const DATE_RANGE_META_START = "aiaDateRangeStart";
1101
+ /** Metadata key holding a committed range's end, as `YYYY-MM-DD`. */
1102
+ declare const DATE_RANGE_META_END = "aiaDateRangeEnd";
1103
+ /**
1104
+ * Best-effort parse of a span of days written in the user's (or the server's)
1105
+ * own words — "September 11 - October 13", "September 11 to November 16",
1106
+ * "13/05/2026 - 20/05/2026".
1107
+ *
1108
+ * Each half goes through {@link parseLooseDate}, so a range reads exactly the
1109
+ * date formats a single date does, and refuses exactly what it refuses. Same
1110
+ * trade as everywhere in this corner of the SDK: **null is a normal outcome**.
1111
+ * It costs the range picker's pre-selection, or keeps a suggestion rendering as
1112
+ * a plain option list — never correctness.
1113
+ *
1114
+ * Refused on purpose:
1115
+ * - A backwards span ("October 13 - September 11"). Nothing here can tell a
1116
+ * typo from a year boundary the writer left implicit, and silently swapping
1117
+ * the ends would answer a question the user didn't ask.
1118
+ * - A shared-month shorthand ("September 11 - 13"). The right half alone is
1119
+ * not a date, and inferring the month from the left half is the kind of
1120
+ * guess this file exists not to make.
1121
+ * - Anything where two different splits both read as a range — the text is
1122
+ * then genuinely ambiguous about where its own separator is.
1123
+ *
1124
+ * A yearless half resolves the way {@link parseLooseDate} resolves one: to its
1125
+ * next occurrence. That is what makes "December 30 - January 5" read as the
1126
+ * turn of the year rather than as a backwards span.
1127
+ */
1128
+ declare function parseLooseDateRange(text: string | undefined | null, opts?: LooseDateOptions): DateRange | null;
1129
+ /**
1130
+ * The committed text for a range — "September 11 - October 13".
1131
+ *
1132
+ * Each end is written by {@link formatAbsoluteDate}, never by `formatDate`:
1133
+ * the weekday shorthand it would use for a nearby day ("Tuesday") names a
1134
+ * different date every week it is read, which a single answer can carry and a
1135
+ * span cannot.
1136
+ *
1137
+ * A span of one day commits as that day alone. "September 11 - September 11"
1138
+ * is not how anyone writes it, and the range is still recoverable from the
1139
+ * option's metadata when the answer is re-edited.
1140
+ */
1141
+ declare function formatDateRange(range: DateRange): string;
1142
+ /**
1143
+ * The range an already-answered param holds, so both its ends can be marked
1144
+ * when it is re-opened.
1145
+ *
1146
+ * Metadata first — it is what the SDK itself wrote, exact and free of the
1147
+ * yearless-date resolution the text form needs. Text second, which is what a
1148
+ * range that reached the input any other way (a server-identified span, a
1149
+ * consumer's controlled value) has.
1150
+ */
1151
+ declare function selectedRangeFor(param: {
1152
+ text?: string;
1153
+ metadata?: Record<string, unknown>;
1154
+ } | undefined | null, opts?: LooseDateOptions): DateRange | null;
1155
+
1156
+ /**
1157
+ * What a calendar paints as answered.
1158
+ *
1159
+ * Three fields rather than one because they answer three different questions,
1160
+ * and a range picker shows two of them at once: what is already committed
1161
+ * (`selectedIso` for a day, `selectedRange` for a span) and what the user is
1162
+ * half-way through picking (`rangeStart`). {@link visibleDateRange} folds the
1163
+ * last two into the span actually drawn.
1164
+ */
1165
+ interface DateSelection {
1166
+ /** `YYYY-MM-DD` a re-edited single-date param already holds, or null. */
1167
+ selectedIso: string | null;
1168
+ /** Both ends of a re-edited range param, or null. */
1169
+ selectedRange: DateRange | null;
1170
+ /** The first end of a range being picked right now, or null. */
1171
+ rangeStart: string | null;
1172
+ }
1173
+ /**
1174
+ * The calendar's selection, read off core state.
1175
+ *
1176
+ * Every package's dropdown needs exactly this, and computing it four times
1177
+ * over — vanilla Tier 1, vanilla Tier 2, the React hook, the Angular
1178
+ * controller — is how the four copies would come to disagree about which cells
1179
+ * a re-edited range fills. Typed structurally so each of them can hand it the
1180
+ * state object it already has.
1181
+ */
1182
+ declare function dateSelectionFor(state: {
1183
+ activeFormatType: FormatType;
1184
+ editingIdentified: {
1185
+ iso: string | null;
1186
+ isoEnd: string | null;
1187
+ } | null;
1188
+ editingParam: {
1189
+ text: string;
1190
+ metadata?: Record<string, unknown>;
1191
+ } | null;
1192
+ dateRangeStart: string | null;
1193
+ }): DateSelection;
1194
+ /**
1195
+ * The span the calendar should paint right now.
1196
+ *
1197
+ * While a first end is pending, that is the live span between it and whatever
1198
+ * the user is pointing at — the preview every range picker shows, which is also
1199
+ * what tells them the first tap registered. Otherwise it is the answer already
1200
+ * committed, if there is one.
1201
+ */
1202
+ declare function visibleDateRange(args: {
1203
+ pendingStart: string | null;
1204
+ highlightedIso: string | null;
1205
+ selected: DateRange | null;
1206
+ }): DateRange | null;
1207
+ /**
1208
+ * How one cell renders against the current selection: filled at either end of
1209
+ * the span, banded in between.
1210
+ *
1211
+ * Shared by all three packages' calendars so a range can't be painted three
1212
+ * subtly different ways. `selectedIso` is the single-date answer, which is
1213
+ * mutually exclusive with `range` in practice but costs nothing to fold in
1214
+ * here — it keeps every cell's appearance one function call at every call site.
1215
+ */
1216
+ declare function dateCellMarks(iso: string | null, args: {
1217
+ selectedIso: string | null;
1218
+ range: DateRange | null;
1219
+ }): {
1220
+ selected: boolean;
1221
+ inRange: boolean;
1222
+ };
1223
+
977
1224
  /**
978
1225
  * Entrance choreography for the dropdown's option rows.
979
1226
  *
@@ -1071,12 +1318,18 @@ declare function needsOptionsGridMeasurement(count: number, isMobile: boolean):
1071
1318
  *
1072
1319
  * Web, five-plus options: two columns of three visible rows — but only when
1073
1320
  * every option provably fits on one line. Rows fill row-major (even indices
1074
- * left, odd right), each column is as wide as its widest option, and the
1075
- * columns may be unequal: the template splits the width in proportion to the
1076
- * two column maxima, so whenever `left + right <= gridWidth`, each column gets
1077
- * at least what its widest row needs. When the pair doesn't fit or there are
1078
- * no usable measurements (SSR, hidden grid, loading skeletons) the layout
1079
- * stays one scrollable column.
1321
+ * left, odd right). The left track is exactly as wide as its widest option
1322
+ * (a fixed pixel width from the measurement) and the right track takes the
1323
+ * rest of the grid which is at least its own widest option, because
1324
+ * `left + right <= gridWidth` is the fit check. So the second column starts
1325
+ * right where the first column's longest text ends, and short options such as
1326
+ * sizes sit beside each other. (Splitting the width in proportion to the two
1327
+ * maxima, as this used to, handed ALL the slack out in that ratio too: one
1328
+ * long option in the left column and "34"-length options on the right gave
1329
+ * the left column ~85% of the box and pushed the right column to the far
1330
+ * edge, 2026-09-03.) When the pair doesn't fit — or there are no usable
1331
+ * measurements (SSR, hidden grid, loading skeletons) — the layout stays one
1332
+ * scrollable column.
1080
1333
  *
1081
1334
  * `rowWidths` are single-line pixel widths of the rendered rows (see
1082
1335
  * `measureOptionsGrid`); `gridWidth` is the grid's content width.
@@ -1236,10 +1489,17 @@ declare function renderEditableContent(args: RenderEditableArgs): void;
1236
1489
  * When the option list is taller than its scroll box, a small round button
1237
1490
  * with a down chevron sits at the bottom-centre of the list. It slides up into
1238
1491
  * view from behind the dropdown's lower edge (the footer, when the dropdown
1239
- * opens below the input) the moment there is more to scroll to, slides back
1240
- * down once the list is scrolled to its end, and scrolls the list a page on
1241
- * click. The reference (the RB2B support panel, 2026-08-19): a white 32 px
1242
- * disc that rises from behind the composer over ~150–180 ms, no fade.
1492
+ * opens below the input) the moment there is more to scroll to, dissolves in
1493
+ * place once the list is scrolled to its end, and scrolls the list a page on
1494
+ * click. The entrance follows the reference (the RB2B support panel,
1495
+ * 2026-08-19): a white 32 px disc that rises from behind the composer over
1496
+ * ~150–180 ms, no fade. The exit deliberately does not mirror it — sliding
1497
+ * back down read as the disc "falling" into the footer (2026-09-03), so
1498
+ * instead it fades out where it stands, with a slight shrink, over
1499
+ * SCROLL_ARROW_LEAVE_MS. The stylesheets key the two moves off two attributes:
1500
+ * `data-aia-visible` (rise, stay) and `data-aia-leaving` (dissolve), which
1501
+ * this controller holds for the fade's length and then clears, so the disc
1502
+ * re-parks below the edge — invisibly — ready to rise again.
1243
1503
  *
1244
1504
  * The DOM differs per package (vanilla builds the button here; React and
1245
1505
  * Angular render their own markup with the same classes and data attributes),
@@ -1262,6 +1522,16 @@ declare const SCROLL_ARROW_CLASS = "magicx-aia-scroll-arrow";
1262
1522
  declare const SCROLL_ARROW_ATTR = "data-aia-scroll-arrow";
1263
1523
  /** Present on the button while it is shown. The stylesheets slide it in on this. */
1264
1524
  declare const SCROLL_ARROW_VISIBLE_ATTR = "data-aia-visible";
1525
+ /**
1526
+ * Present on the button while it fades out. The stylesheets dissolve it in
1527
+ * place on this; the controller clears it after SCROLL_ARROW_LEAVE_MS.
1528
+ */
1529
+ declare const SCROLL_ARROW_LEAVING_ATTR = "data-aia-leaving";
1530
+ /**
1531
+ * Length of the fade-out. Mirrors the `[data-aia-leaving]` transition in each
1532
+ * package's stylesheet — change both together.
1533
+ */
1534
+ declare const SCROLL_ARROW_LEAVE_MS = 240;
1265
1535
  /** Inline custom property: how far the button's bottom edge sits above the dropdown's padding edge. */
1266
1536
  declare const SCROLL_ARROW_BOTTOM_VAR = "--aia-scroll-arrow-bottom";
1267
1537
  declare const SCROLL_ARROW_LABEL = "Scroll down for more options";
@@ -1420,4 +1690,4 @@ interface SubmitResultExtras {
1420
1690
  */
1421
1691
  declare function buildSubmitResult(text: string, completedParams: CompletedParamState[], skippedParams?: SkippedParamState[], extras?: SubmitResultExtras): AutocompleteResult;
1422
1692
 
1423
- 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 SubmitResultExtras, 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, toWireIdentifiedParams, withSkippedParams };
1693
+ 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, DATE_RANGE_META_END, DATE_RANGE_META_START, type DateMonthView, type DateRange, type DateSelection, 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 OptionOverride, 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, RANGE_SEPARATOR, type RecentlySuggested, type RenderMode, SCROLL_ARROW_ATTR, SCROLL_ARROW_BOTTOM_VAR, SCROLL_ARROW_CLASS, SCROLL_ARROW_LABEL, SCROLL_ARROW_LEAVE_MS, SCROLL_ARROW_LEAVING_ATTR, SCROLL_ARROW_VISIBLE_ATTR, SKIPPED_PARAM_TEXT, type ScrollArrowArgs, type ScrollArrowController, type Segment, type SkippedParamState, type Store, type SubmitResultExtras, type Suggestion, type SuggestionOption, type TaskKind, WEEKDAY_LABELS, addMonths, attachScrollArrow, buildAttributionUrl, buildDateOptions, buildQuery, buildSubmitResult, cellDay, cellIso, computeOptionsGridLayout, createStore, cursorIsAtEnd, dateCellMarks, dateSelectionFor, extractPlainText, formatAbsoluteDate, formatDate, formatDateRange, getCursorOffset, getFooterHint, identifiedParamLabel, isCalendarFormat, isOptionsGridMobileViewport, isoDate, measureOptionsGrid, monthLabel, needsOptionsGridMeasurement, optionEnterDelayMs, optionsEntranceDurationMs, optionsGridTemplateColumns, parseDate, parseLooseDate, parseLooseDateRange, plainTextLength, planOptionsGrid, previousGraphemeBoundary, renderEditableContent, resolveFormatType, resolveIdentifiedDate, scrollCaretIntoView, selectedIsoFromText, selectedRangeFor, setCursorOffset, toWireIdentifiedParams, visibleDateRange, withSkippedParams };