@magicx-eng/ai-autocomplete-vanilla 0.13.1 → 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,13 +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 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.
17
18
  - **Option overrides** — inject or dynamically generate client-side options per suggestion type
18
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`
19
20
  - **Controlled & uncontrolled** — works out of the box or integrates with external state
20
21
  - **Accessible** — ARIA combobox 1.2 pattern with `role="listbox"`, `aria-activedescendant`
21
22
  - **IME-safe** — composition events are buffered so input text is committed once, after composition ends
22
- - **Animations** — option selection streak animation, text shimmer on newly added params
23
- - **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.
24
25
  - **Lightweight** — ~10 KB gzipped, styles auto-injected at runtime
25
26
  - **TypeScript first** — full type definitions shipped with the package
26
27
  - **SSR-safe** — no top-level `document`/`window` access
@@ -80,7 +81,7 @@ const ac = new AIAutocomplete(container, {
80
81
  // Appearance
81
82
  mode: "auto", // "light" | "dark" | "auto"
82
83
  optionsPosition: "below", // "above" | "below"
83
- animations: true, // enable streak + shimmer
84
+ animations: true, // press, shimmer, typed placeholder, staggered options, scroll arrow
84
85
  pillPlacement: "dropdown", // "inline" | "dropdown" | "hidden"
85
86
  dropdownTrigger: "auto", // "auto" | "manual" | "hidden"
86
87
  closeDropdownOnBlur: true, // false = keep dropdown open even when input loses focus
@@ -260,6 +261,85 @@ new AIAutocomplete(container, {
260
261
  new AIAutocomplete(container, { ..., submitButton: null });
261
262
  ```
262
263
 
264
+ ### Datepicker
265
+
266
+ When the next parameter is a date, the dropdown shows a calendar instead of a
267
+ list of options. A parameter counts as a date when its name says so, or when at
268
+ least three of the options it offers are themselves written as dates — so a
269
+ parameter called `when` or `arrival` still gets a calendar. Options written as
270
+ relative phrases (`today`, `next week`) are not read as dates; a parameter
271
+ offering those needs a name that says date.
272
+
273
+ Nothing is required to switch it on — a date parameter renders this way
274
+ automatically, and every other parameter is unaffected. Tier 1 and Tier 2 render
275
+ the calendar for you; in Tier 3 the same data reaches your own renderer (see
276
+ [Tier 3](#tier-3-headless)).
277
+
278
+ **What the user can do**
279
+
280
+ | Input | Result |
281
+ |---|---|
282
+ | Click a day | Commits that date |
283
+ | <kbd>↓</kbd> | Moves into the calendar, starting on today |
284
+ | <kbd>←</kbd> / <kbd>→</kbd> | Previous / next day, crossing week boundaries |
285
+ | <kbd>↑</kbd> / <kbd>↓</kbd> | Previous / next week. Past either end of the month, focus returns to the input |
286
+ | <kbd>Enter</kbd> | Commits the highlighted day |
287
+ | Month arrows | Page the calendar. Paging never commits anything |
288
+ | <kbd>→</kbd> at the end of the input | Skips the parameter, same as any other pill |
289
+
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,
301
+ and present in `completed_params` on submit like any other answer.
302
+
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.
308
+
309
+ **Reading the value back.** The date arrives as text, so if you need a `Date`
310
+ object, `parseDate` is exported for it:
311
+
312
+ ```ts
313
+ import { parseDate } from "@magicx-eng/ai-autocomplete-vanilla";
314
+
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
318
+ ```
319
+
320
+ `parseLooseDate` is exported too, for the looser shapes a person types into a
321
+ query — it reads `september 5th`, `5th September`, `Sept 5` and `2026-09-05`,
322
+ and answers `null` for anything ambiguous rather than guessing.
323
+
324
+ **Dates the user writes themselves.** When someone types a date into their query
325
+ — "fly to vegas on september 5th" — the parameter it answers is recognised in
326
+ place and the date becomes tappable. Tapping it opens the calendar on that
327
+ month with the day already marked, so confirming or changing it takes one tap,
328
+ and the picked date replaces their words with the canonical form.
329
+
330
+ The written date is read where it can be: `september 5th`, `5th September`,
331
+ `Sept 5`, `September 5, 2026` and `2026-09-05` all resolve, in any of those
332
+ orders and with or without the year (a date with no year means its next
333
+ occurrence). Two cases deliberately don't pre-select — a numeric date like
334
+ `09/05`, which is September 5th in the US and May 9th elsewhere with nothing to
335
+ say which, and phrases like `next friday`. Those still open the calendar, just
336
+ on the current month with nothing marked, so the user picks rather than being
337
+ shown a guess that might be wrong.
338
+
339
+ **Typing** while the calendar is open does not filter it. The text is treated as
340
+ a new query, so suggestions refresh as the user types — useful when someone
341
+ would rather describe what they want than pick a day.
342
+
263
343
  ---
264
344
 
265
345
  ## Tier 2: Dropdown Only
@@ -295,6 +375,8 @@ The [product strip](#product-strip) works here too: pass `products` / `onProduct
295
375
 
296
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.
297
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
+
298
380
  ---
299
381
 
300
382
  ## Tier 3: Headless
@@ -344,6 +426,8 @@ unsub();
344
426
 
345
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.
346
428
 
429
+
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`.
347
431
  ### State shape (`CoreState`)
348
432
 
349
433
  | Field | Type | Description |
@@ -352,14 +436,16 @@ unsub();
352
436
  | `completedParams` | `CompletedParamState[]` | Filled parameters |
353
437
  | `suggestions` | `Suggestion[]` | All suggestions from server (including placeholder type) |
354
438
  | `actionableSuggestions` | `Suggestion[]` | Non-placeholder suggestions (the pills) |
355
- | `filteredOptions` | `SuggestionOption[]` | Options for the active suggestion, filtered by current query |
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`. |
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. |
441
+ | `dateView` | `DateMonthView \| null` | The month the calendar is showing (`{ year, month }`, 0-based month). Null unless `activeFormatType` is `"date"`. |
356
442
  | `segments` | `Segment[]` | Input text split into typed text vs completed params — completed segments render as bold `<strong>` runs inside the editor |
357
443
  | `placeholderText` | `string` | Placeholder text from server suggestions (joined `placeholder`-type suggestion texts) |
358
444
  | `activeDropdownIndex` | `number` | Highlighted option index (-1 = none) |
359
445
  | `isDropdownOpen` | `boolean` | Whether the dropdown should be visible |
360
446
  | `newParamId` | `string \| null` | ID of the most recently added param (for shimmer animation) |
361
447
  | `isLoading` | `boolean` | Fetch in progress. The Tier 1 / Tier 2 renderers gate the loading skeleton additionally on `!inSelectionAnimation` and `!editingParam`. |
362
- | `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. |
363
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. |
364
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`. |
365
451
  | `isReady` | `boolean` | Server indicates query is complete |
@@ -370,6 +456,7 @@ unsub();
370
456
  | Method | Description |
371
457
  |---|---|
372
458
  | `handleTextChange(value)` | Call when user types. Handles capitalization, param reconciliation, filter updates. |
459
+ | `showPreviousMonth()` / `showNextMonth()` | Page the calendar a month at a time; paging never commits anything. Tier 1 and Tier 2 wire the built-in month arrows to these, so you only need them for a hand-rolled calendar. No-ops unless the active parameter is a date. Moving on to a different parameter resets the calendar to the current month. |
373
460
  | `handleKeyDown(event)` | Forward keyboard events. Handles arrow nav, Enter, Tab, Escape. |
374
461
  | `setFocused(focused)` | Notify the library that the input has focus. Required for `dropdownTrigger: "auto"` in Tier 2/3 — the dropdown only opens while focused. |
375
462
  | `handleCaretMove(offset)` | Report the caret position (plain-text offset). For a **custom editor** that isn't a `<textarea>`/`<input>` (e.g. contentEditable / rich-text), this is what lets arrow keys move into the dropdown. The library reads a `<textarea>`/`<input>`'s caret directly, so you only need this for custom editors. |
@@ -398,6 +485,30 @@ The pattern for any framework:
398
485
  4. Forward user events (input, keydown, click) → core actions
399
486
  5. Destroy on unmount
400
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
+
401
512
  ---
402
513
 
403
514
  ## API Reference
@@ -510,13 +621,17 @@ Override these on the container element. All built-in defaults use `:where()` (z
510
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. |
511
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. |
512
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). |
513
- | `--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), …)`). |
514
- | `--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. |
515
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. |
516
626
  | `--aia-product-gap` | `8px` | `8px` | Gap between product cards. |
517
627
  | `--aia-product-bg` | `transparent` | `transparent` | Product card background. |
518
628
  | `--aia-product-bg-active` | `--aia-option-bg` | `--aia-option-bg` | Product card background on hover. |
519
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). |
520
635
  | `--aia-product-placeholder-color` | `--aia-option-color` | `--aia-option-color` | Glyph color of the no-image placeholder tile. |
521
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. |
522
637
  | `--aia-product-price-color` | `--aia-option-color-selected` | `--aia-option-color-selected` | Product price text. |
@@ -527,6 +642,14 @@ Override these on the container element. All built-in defaults use `:where()` (z
527
642
  | `--aia-product-price-font-size` | `11px` | `11px` | Product price font size. |
528
643
  | `--aia-product-vendor-font-size` | `10px` | `10px` | Product vendor font size. |
529
644
  | `--aia-products-label-font-size` | `11px` | `11px` | Section label 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. |
649
+ | `--aia-date-month-font-size` | `14px` | `14px` | Font size of the "March 2026" header. |
650
+ | `--aia-date-weekday-font-size` | `11px` | `11px` | Font size of the S/M/T/W/T/F/S column letters. |
651
+ | `--aia-date-today-ring` | `--aia-option-color` | `--aia-option-color` | Outline drawn around today's date. |
652
+ | `--aia-date-selected-bg` | white at 12% | white at 12% | Fill behind the date a re-edited parameter already holds. |
530
653
  | `--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. |
531
654
 
532
655
  ### Per-mode Overrides
@@ -562,7 +685,13 @@ For styling beyond the CSS variables, target these stable `data-aia-*` attribute
562
685
  | `[data-aia-pill-scroll]` | Scrollable pill region inside the bar — carries the horizontal scroll and right-edge fade mask |
563
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` |
564
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 |
565
689
  | `[data-aia-dropdown]` | The dropdown root (listbox). Carries `data-aia-has-products` while the product strip has cards. |
690
+ | `[data-aia-datepicker]` | The calendar, rendered in place of the option list for a date parameter |
691
+ | `[data-aia-date-month]` | The "March 2026" header label |
692
+ | `[data-aia-date-prev]` / `[data-aia-date-next]` | The month arrows |
693
+ | `[data-aia-date-grid]` | The 7-column grid of day cells |
694
+ | `[data-aia-date-cell]` | Each day cell. Also carries `[data-aia-option]`, so option-level styling applies to both bodies |
566
695
  | `[data-aia-products]` | Product strip section (label + row) |
567
696
  | `[data-aia-products-row]` | The horizontally scrolling row of cards |
568
697
  | `[data-aia-product]` | Each product card |
@@ -590,7 +719,20 @@ Every `/api/suggest` request carries a `meta.session_id` UUID. A session runs fr
590
719
  - **Tier 1 (`renderMode: "full"`)** — the SDK auto-resets after both Enter key and built-in submit-button clicks. Your `onSubmit` runs first, then the SDK clears the input and starts a new session.
591
720
  - **Tier 2/3 (`renderMode: "dropdown" | "headless"`)** — you own the input and submit flow, so you must call `ac.reset()` yourself from your `onSubmit` handler (or from your custom submit button after firing `onSubmit`).
592
721
 
593
- > **Why it matters:** the server uses `session_id` to track each session's history — what the user has been typing sequentially, which options they've selected, and how the query evolved. That context lets the model produce better, more relevant suggestions on subsequent requests within the same session. Calling `reset()` at the right moment (when the user actually submits) keeps that history accurate, so your users get higher-quality results.
722
+ > **Why it matters:** suggestions get sharper as a query develops. Each one
723
+ > takes account of what the user has already answered, so the parameters offered
724
+ > late in a query are shaped by the choices made early in it — that is what makes
725
+ > the experience feel guided rather than like a static list. `session_id` is what
726
+ > ties those requests together into one query.
727
+ >
728
+ > So `reset()` is not bookkeeping: it is how you say "that query is finished".
729
+ > Skip it and the next query is treated as a continuation of the last one, and
730
+ > its suggestions keep being shaped by answers the user has already moved on
731
+ > from — the failure is quiet, and shows up as steadily less relevant options
732
+ > rather than as an error.
733
+
734
+ The id is a plain UUID. Log it next to your own request logs if you want to
735
+ correlate a user's report with the query that produced it.
594
736
 
595
737
  ```ts
596
738
  // Tier 2/3 — call reset() yourself