@magicx-eng/ai-autocomplete-vanilla 0.1.13 → 0.1.15

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
@@ -6,15 +6,18 @@ A framework-agnostic vanilla JS/TypeScript library that provides a guided AI-pow
6
6
 
7
7
  - **Three tiers of integration** — full drop-in component, dropdown-only, or fully headless
8
8
  - **Zero dependencies** — no React, no framework, no virtual DOM
9
- - **Pill-based input** — inline pills for unfilled parameters, completed text for filled ones
10
- - **Pill placement** — render pills inline in the input or inside the dropdown
9
+ - **Rich inline input** — a single `contentEditable` surface: typed text, bold completed params, and inline pills all share one editing context
10
+ - **Pill-based input** — non-editable inline pills for unfilled parameters, bold inline text for completed ones
11
+ - **Instant exact-match bolding** — typing the full text of an option immediately promotes it to a completed param (no debounced fetch wait). Works in the normal typing flow *and* while re-editing an existing completed param.
12
+ - **Re-edit completed params** — tap a bold completed param to replace it; the dropdown re-opens with the cached options the server originally returned for that param
11
13
  - **Light/dark mode** — built-in themes with `prefers-color-scheme` support, fully overridable via CSS variables
12
14
  - **Access token auth** — short-lived tokens with automatic refresh, single-flight deduplication, and 401 retry
13
- - **Keyboard navigation** — arrow keys, enter to submit, tab to autocomplete
15
+ - **Keyboard navigation** — arrow keys, enter to submit, tab to autocomplete, backspace to un-bold the last completed param
14
16
  - **Client-side filtering** — instant substring filtering on every keystroke
15
17
  - **Option overrides** — inject or dynamically generate client-side options per suggestion type
16
18
  - **Controlled & uncontrolled** — works out of the box or integrates with external state
17
- - **Accessible** — ARIA combobox pattern with `role="listbox"`, `aria-activedescendant`
19
+ - **Accessible** — ARIA combobox 1.2 pattern with `role="listbox"`, `aria-activedescendant`
20
+ - **IME-safe** — composition events are buffered so input text is committed once, after composition ends
18
21
  - **Animations** — option selection streak animation, text shimmer on newly added params
19
22
  - **Lightweight** — ~10 KB gzipped, styles auto-injected at runtime
20
23
  - **TypeScript first** — full type definitions shipped with the package
@@ -115,7 +118,13 @@ ac.update({ animations: false, optionsPosition: "above" });
115
118
 
116
119
  > **Tier 1 auto-resets** after both Enter key and built-in submit-button clicks — your `onSubmit` runs, then the SDK clears the input and starts a new session. You don't need to call `ac.reset()` yourself in Tier 1.
117
120
 
118
- > `autoFocus`, `focus()`, and `blur()` only operate on the textarea the library owns (Tier 1 / `renderMode: "full"`). In Tier 2/3, the consumer owns the input and calls `.focus()` / `.blur()` on their own element; the `onFocus` / `onBlur` callbacks still fire whenever `setFocused()` is called.
121
+ > `autoFocus`, `focus()`, and `blur()` only operate on the contentEditable editor the library owns (Tier 1 / `renderMode: "full"`). In Tier 2/3, the consumer owns the input and calls `.focus()` / `.blur()` on their own element; the `onFocus` / `onBlur` callbacks still fire whenever `setFocused()` is called.
122
+
123
+ > **Backspace into a completed param** — pressing Backspace while the caret is inside (or immediately after) a bold completed param drops the param's "completed" status and removes one grapheme before the caret. The remaining text stays in the editor as plain (un-bold) text so the user can keep editing instead of losing the whole phrase.
124
+
125
+ > **Re-edit a completed param** — tapping a bold completed param enters *re-edit mode*. The dropdown re-opens with the cached options the server originally returned for that param. Typing atomically replaces the bold (and re-promotes it to bold if what you type exactly matches one of the cached options); clicking an option replaces it with the new selection; pressing Escape or moving the caret out exits without changes.
126
+
127
+ > **Caret placement after a completion** — whenever a completed param is added (by any means — option click, exact-match typing, or re-edit), the caret lands right after the trailing space following the bold so typing can continue immediately. A space is inserted if one wasn't already there.
119
128
 
120
129
  #### Custom submit button
121
130
 
@@ -221,7 +230,7 @@ unsub();
221
230
  | `suggestions` | `Suggestion[]` | All suggestions from server (including placeholder type) |
222
231
  | `actionableSuggestions` | `Suggestion[]` | Non-placeholder suggestions (the pills) |
223
232
  | `filteredOptions` | `SuggestionOption[]` | Options for the active suggestion, filtered by current query |
224
- | `segments` | `Segment[]` | Input text split into typed text vs completed params — for overlay rendering |
233
+ | `segments` | `Segment[]` | Input text split into typed text vs completed params — completed segments render as bold `<strong>` runs inside the editor |
225
234
  | `placeholderText` | `string` | Placeholder text from server suggestions (joined `placeholder`-type suggestion texts) |
226
235
  | `activeDropdownIndex` | `number` | Highlighted option index (-1 = none) |
227
236
  | `isDropdownOpen` | `boolean` | Whether the dropdown should be visible |
@@ -240,7 +249,7 @@ unsub();
240
249
  | `selectOption(option)` | Select a dropdown option. Updates text, creates completed param, triggers shimmer. |
241
250
  | `setActiveDropdownIndex(index)` | Set the highlighted option (for mouse hover). |
242
251
  | `setActivePill(index)` | Reorder pills — moves pill at `index` to front (active). |
243
- | `removeLastParam()` | Remove last completed param, restore it as a pill. |
252
+ | `removeLastParam()` | Remove the last completed param from state. The text stays in the input as plain text. |
244
253
  | `clearNewParamId()` | Clear shimmer animation state. |
245
254
  | `reset()` | Clear all state, re-fetch initial suggestions, and start a new session (rotates `session_id`). Call this after handling submit. |
246
255
  | `setValue(text)` | Set text (controlled mode). |
@@ -332,6 +341,7 @@ Override these on the container element. All built-in defaults use `:where()` (z
332
341
 
333
342
  | Variable | Default (light) | Default (dark) | Description |
334
343
  |---|---|---|---|
344
+ | `--aia-font-family` | `inherit` | `inherit` | Font used by the library. Defaults to `inherit` so the library picks up your page's font automatically. Set this to pin a specific font on the library without changing the surrounding page. |
335
345
  | `--aia-pill-bg` | `#bdbdbd` | `#bdbdbd` | Pill background |
336
346
  | `--aia-pill-color` | `#000000` | `#ffffff` | Pill text |
337
347
  | `--aia-pill-font-size` | `19px` | `19px` | Pill font size |
@@ -341,7 +351,7 @@ Override these on the container element. All built-in defaults use `:where()` (z
341
351
  | `--aia-option-font-size` | `19px` | `19px` | Option font size |
342
352
  | `--aia-written-text-color` | `#000000` | `#ffffff` | Input text |
343
353
  | `--aia-written-text-font-size` | `19px` | `19px` | Input text font size |
344
- | `--aia-caret-color` | `--aia-written-text-color` | `--aia-written-text-color` | Textarea caret color. Override independently of input text color. |
354
+ | `--aia-caret-color` | `--aia-written-text-color` | `--aia-written-text-color` | Editor caret color. Override independently of input text color. |
345
355
  | `--aia-submit-bg` | `#000000` | `#ffffff` | Submit button background |
346
356
  | `--aia-submit-color` | `#ffffff` | `#000000` | Submit button icon color |
347
357
  | `--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. |
@@ -373,14 +383,17 @@ For styling beyond the CSS variables, target these stable `data-aia-*` attribute
373
383
 
374
384
  | Attribute | Element |
375
385
  |---|---|
376
- | `[data-aia-editor]` | Editor area wrapping the textarea + overlay |
377
- | `[data-aia-textarea]` | The `<textarea>` |
386
+ | `[data-aia-editor]` | Editor area wrapping the contentEditable + inline pill list |
387
+ | `[data-aia-input]` | The contentEditable `<div>` that owns typed text and bold completed params. Replaces the previous `[data-aia-textarea]` selector. |
388
+ | `[data-aia-pill-list-container]` | Inline sibling of the editor that holds unfilled-suggestion pills |
378
389
  | `[data-aia-submit]` | Submit button |
379
390
  | `[data-aia-pill]` | Each unfilled-suggestion pill |
380
391
  | `[data-aia-pillbar]` | Pill bar container inside the dropdown |
381
392
  | `[data-aia-option]` | Each suggestion option |
382
393
  | `[data-aia-dropdown]` | The dropdown root (listbox) |
383
394
 
395
+ Completed params render as inline `<strong>` elements inside the editor. Override their weight with `[data-aia-input] strong { font-weight: 700; }` (the built-in style uses `:where()` so any consumer selector wins without `!important`).
396
+
384
397
  The vanilla core also exposes stable BEM class names (`magicx-aia-*`); both can be used.
385
398
 
386
399
  ```css
package/index.d.mts CHANGED
@@ -22,8 +22,11 @@ interface Suggestion {
22
22
  interface CompletedParamState extends CompletedParam {
23
23
  id: string;
24
24
  text: string;
25
+ /** Source suggestion's `type` — used by re-edit mode to render the dropdown pill. */
25
26
  suggestionType: string;
27
+ /** Source suggestion's `text` — used by re-edit mode to render the dropdown pill. */
26
28
  suggestionPlaceholder: string;
29
+ /** Cached options from the suggestion that produced this param — re-edit shows these. */
27
30
  options: SuggestionOption[];
28
31
  metadata?: Record<string, unknown>;
29
32
  }
@@ -85,6 +88,19 @@ interface CoreState {
85
88
  skipNextFetch: boolean;
86
89
  lastRawQuery: string;
87
90
  isFocused: boolean;
91
+ /**
92
+ * Re-edit state: while non-null, the user is editing a bold completed param.
93
+ * The snapshot stays here even after the param is removed from
94
+ * `completedParams` (when typing/backspace replaces its text), so the
95
+ * dropdown can keep filtering the cached options.
96
+ */
97
+ editingParam: CompletedParamState | null;
98
+ /** Plain-text offset where the edited param started (anchor for the edit region). */
99
+ editingAnchor: number | null;
100
+ /** Plain-text offset of the right edge of the edit region. Caret outside [anchor, tail] exits edit mode. */
101
+ editingTail: number | null;
102
+ /** Current caret offset within the editor. Tracked via DOM `selectionchange`. */
103
+ caretOffset: number | null;
88
104
  }
89
105
  /**
90
106
  * Render mode:
@@ -136,6 +152,14 @@ interface CoreOptions {
136
152
  onBlur?: () => void;
137
153
  value?: string;
138
154
  completedParams?: CompletedParamState[];
155
+ /**
156
+ * Headless-mode caret setter. The core invokes this in place of writing to
157
+ * its own `domRefs.input` when re-edit / Backspace flows need to park the
158
+ * caret at a specific plain-text offset. Wrappers that own the DOM (React,
159
+ * etc.) must provide this — vanilla "full" mode falls back to `domRefs`.
160
+ * @internal
161
+ */
162
+ setCursor?: (offset: number) => void;
139
163
  }
140
164
 
141
165
  declare class AIAutocomplete {
@@ -166,7 +190,54 @@ declare class AIAutocomplete {
166
190
  setCompletedParams(params: CompletedParamState[]): void;
167
191
  setActivePill(index: number): void;
168
192
  removeLastParam(): void;
193
+ /**
194
+ * Backspace inside a completed param: drop the param's "completed" status so
195
+ * it renders as plain (un-bold) text, AND remove one grapheme before the
196
+ * caret — same single-character delete a normal Backspace would do. The
197
+ * remaining text stays in the input so the user can continue editing what
198
+ * they had typed instead of losing the whole phrase.
199
+ *
200
+ * Returns true when a param was reconciled (caller should `preventDefault`).
201
+ */
202
+ removeParamAtCaret(offset: number): boolean;
203
+ /**
204
+ * Set the editor caret at the given plain-text offset. Uses the core's own
205
+ * `domRefs.input` in "full" mode; falls back to the wrapper-provided
206
+ * `setCursor` callback in "headless" mode where the wrapper owns the DOM.
207
+ */
208
+ private scheduleSetCursor;
169
209
  clearNewParamId(): void;
210
+ /**
211
+ * Enter re-edit mode for the bold completed param with the given id. The
212
+ * caller is responsible for highlighting the param via the Selection API
213
+ * (DOM concern). This method just sets the state snapshot so the dropdown
214
+ * starts filtering the param's cached options.
215
+ */
216
+ startEditingParam(paramId: string): void;
217
+ /**
218
+ * Replace the editing range (`text[editingAnchor..editingTail]`) with the
219
+ * given replacement. Used by the DOM layer's `beforeinput` intercept: while
220
+ * the edited param's strong still exists in `completedParams`, typing or
221
+ * backspace should swap the WHOLE param atomically (instead of inserting
222
+ * one char into the bold span). Returns true when the replacement was
223
+ * applied — callers should `preventDefault` only in that case.
224
+ */
225
+ replaceEditingRange(replacement: string): boolean;
226
+ /** Clear re-edit state. Selection collapsing is a DOM concern handled by the caller. */
227
+ exitEditMode(): void;
228
+ /**
229
+ * Update caret offset after a text-input event. While in edit mode, extends
230
+ * `editingTail` to keep up with the user typing past the previous tail.
231
+ * Exits edit mode if the user backspaced past `editingAnchor` (deleting a
232
+ * character before where the edit started).
233
+ */
234
+ handleCaretAfterInput(offset: number | null): void;
235
+ /**
236
+ * Update caret offset from a selection-move event that was NOT preceded by
237
+ * an input event (so the user moved the cursor by click or arrows, not by
238
+ * typing). Exits edit mode if the caret moved outside the edit region.
239
+ */
240
+ handleCaretMove(offset: number | null): void;
170
241
  setActiveDropdownIndex(index: number): void;
171
242
  handleTextChange(value: string): void;
172
243
  handleKeyDown(e: KeyboardEvent): void;
@@ -179,6 +250,7 @@ declare class AIAutocomplete {
179
250
  on(event: string, callback: (...args: unknown[]) => void): () => void;
180
251
  update(opts: Partial<CoreOptions>): void;
181
252
  selectOption(option: SuggestionOption): void;
253
+ private selectOptionInEditMode;
182
254
  private fireTelemetry;
183
255
  private recomputeDerived;
184
256
  private setupContainer;
@@ -189,6 +261,22 @@ declare class AIAutocomplete {
189
261
  /** Auto-clear newParamId after shimmer animation. */
190
262
  private subscribeNewParamTimer;
191
263
  private handleChange;
264
+ /**
265
+ * When the user has typed text that exactly matches (case-insensitive) one
266
+ * of the active suggestion's options, promote it to a completed param right
267
+ * away. The fetchController does the same check when the debounced fetch
268
+ * lands; doing it instantly here means bold styling appears as soon as the
269
+ * option is fully typed, without waiting 100–300ms for the round-trip.
270
+ */
271
+ private maybePromoteExactMatch;
272
+ /**
273
+ * Edit-mode counterpart to {@link maybePromoteExactMatch}. When the user is
274
+ * editing a bold completed param and the typed text exactly matches one of
275
+ * that param's cached options, promote it back to a completed param (bold)
276
+ * — same instant-bold affordance as the normal typing flow, just driven by
277
+ * the edited param's cached options instead of the active suggestion.
278
+ */
279
+ private maybePromoteEditExactMatch;
192
280
  }
193
281
 
194
282
  type Listener<S> = (next: S, prev: S) => void;
package/index.d.ts CHANGED
@@ -22,8 +22,11 @@ interface Suggestion {
22
22
  interface CompletedParamState extends CompletedParam {
23
23
  id: string;
24
24
  text: string;
25
+ /** Source suggestion's `type` — used by re-edit mode to render the dropdown pill. */
25
26
  suggestionType: string;
27
+ /** Source suggestion's `text` — used by re-edit mode to render the dropdown pill. */
26
28
  suggestionPlaceholder: string;
29
+ /** Cached options from the suggestion that produced this param — re-edit shows these. */
27
30
  options: SuggestionOption[];
28
31
  metadata?: Record<string, unknown>;
29
32
  }
@@ -85,6 +88,19 @@ interface CoreState {
85
88
  skipNextFetch: boolean;
86
89
  lastRawQuery: string;
87
90
  isFocused: boolean;
91
+ /**
92
+ * Re-edit state: while non-null, the user is editing a bold completed param.
93
+ * The snapshot stays here even after the param is removed from
94
+ * `completedParams` (when typing/backspace replaces its text), so the
95
+ * dropdown can keep filtering the cached options.
96
+ */
97
+ editingParam: CompletedParamState | null;
98
+ /** Plain-text offset where the edited param started (anchor for the edit region). */
99
+ editingAnchor: number | null;
100
+ /** Plain-text offset of the right edge of the edit region. Caret outside [anchor, tail] exits edit mode. */
101
+ editingTail: number | null;
102
+ /** Current caret offset within the editor. Tracked via DOM `selectionchange`. */
103
+ caretOffset: number | null;
88
104
  }
89
105
  /**
90
106
  * Render mode:
@@ -136,6 +152,14 @@ interface CoreOptions {
136
152
  onBlur?: () => void;
137
153
  value?: string;
138
154
  completedParams?: CompletedParamState[];
155
+ /**
156
+ * Headless-mode caret setter. The core invokes this in place of writing to
157
+ * its own `domRefs.input` when re-edit / Backspace flows need to park the
158
+ * caret at a specific plain-text offset. Wrappers that own the DOM (React,
159
+ * etc.) must provide this — vanilla "full" mode falls back to `domRefs`.
160
+ * @internal
161
+ */
162
+ setCursor?: (offset: number) => void;
139
163
  }
140
164
 
141
165
  declare class AIAutocomplete {
@@ -166,7 +190,54 @@ declare class AIAutocomplete {
166
190
  setCompletedParams(params: CompletedParamState[]): void;
167
191
  setActivePill(index: number): void;
168
192
  removeLastParam(): void;
193
+ /**
194
+ * Backspace inside a completed param: drop the param's "completed" status so
195
+ * it renders as plain (un-bold) text, AND remove one grapheme before the
196
+ * caret — same single-character delete a normal Backspace would do. The
197
+ * remaining text stays in the input so the user can continue editing what
198
+ * they had typed instead of losing the whole phrase.
199
+ *
200
+ * Returns true when a param was reconciled (caller should `preventDefault`).
201
+ */
202
+ removeParamAtCaret(offset: number): boolean;
203
+ /**
204
+ * Set the editor caret at the given plain-text offset. Uses the core's own
205
+ * `domRefs.input` in "full" mode; falls back to the wrapper-provided
206
+ * `setCursor` callback in "headless" mode where the wrapper owns the DOM.
207
+ */
208
+ private scheduleSetCursor;
169
209
  clearNewParamId(): void;
210
+ /**
211
+ * Enter re-edit mode for the bold completed param with the given id. The
212
+ * caller is responsible for highlighting the param via the Selection API
213
+ * (DOM concern). This method just sets the state snapshot so the dropdown
214
+ * starts filtering the param's cached options.
215
+ */
216
+ startEditingParam(paramId: string): void;
217
+ /**
218
+ * Replace the editing range (`text[editingAnchor..editingTail]`) with the
219
+ * given replacement. Used by the DOM layer's `beforeinput` intercept: while
220
+ * the edited param's strong still exists in `completedParams`, typing or
221
+ * backspace should swap the WHOLE param atomically (instead of inserting
222
+ * one char into the bold span). Returns true when the replacement was
223
+ * applied — callers should `preventDefault` only in that case.
224
+ */
225
+ replaceEditingRange(replacement: string): boolean;
226
+ /** Clear re-edit state. Selection collapsing is a DOM concern handled by the caller. */
227
+ exitEditMode(): void;
228
+ /**
229
+ * Update caret offset after a text-input event. While in edit mode, extends
230
+ * `editingTail` to keep up with the user typing past the previous tail.
231
+ * Exits edit mode if the user backspaced past `editingAnchor` (deleting a
232
+ * character before where the edit started).
233
+ */
234
+ handleCaretAfterInput(offset: number | null): void;
235
+ /**
236
+ * Update caret offset from a selection-move event that was NOT preceded by
237
+ * an input event (so the user moved the cursor by click or arrows, not by
238
+ * typing). Exits edit mode if the caret moved outside the edit region.
239
+ */
240
+ handleCaretMove(offset: number | null): void;
170
241
  setActiveDropdownIndex(index: number): void;
171
242
  handleTextChange(value: string): void;
172
243
  handleKeyDown(e: KeyboardEvent): void;
@@ -179,6 +250,7 @@ declare class AIAutocomplete {
179
250
  on(event: string, callback: (...args: unknown[]) => void): () => void;
180
251
  update(opts: Partial<CoreOptions>): void;
181
252
  selectOption(option: SuggestionOption): void;
253
+ private selectOptionInEditMode;
182
254
  private fireTelemetry;
183
255
  private recomputeDerived;
184
256
  private setupContainer;
@@ -189,6 +261,22 @@ declare class AIAutocomplete {
189
261
  /** Auto-clear newParamId after shimmer animation. */
190
262
  private subscribeNewParamTimer;
191
263
  private handleChange;
264
+ /**
265
+ * When the user has typed text that exactly matches (case-insensitive) one
266
+ * of the active suggestion's options, promote it to a completed param right
267
+ * away. The fetchController does the same check when the debounced fetch
268
+ * lands; doing it instantly here means bold styling appears as soon as the
269
+ * option is fully typed, without waiting 100–300ms for the round-trip.
270
+ */
271
+ private maybePromoteExactMatch;
272
+ /**
273
+ * Edit-mode counterpart to {@link maybePromoteExactMatch}. When the user is
274
+ * editing a bold completed param and the typed text exactly matches one of
275
+ * that param's cached options, promote it back to a completed param (bold)
276
+ * — same instant-bold affordance as the normal typing flow, just driven by
277
+ * the edited param's cached options instead of the active suggestion.
278
+ */
279
+ private maybePromoteEditExactMatch;
192
280
  }
193
281
 
194
282
  type Listener<S> = (next: S, prev: S) => void;