@magicx-eng/ai-autocomplete-vanilla 0.1.17 → 0.1.18

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
@@ -82,6 +82,7 @@ const ac = new AIAutocomplete(container, {
82
82
  pillPlacement: "inline", // "inline" | "dropdown" | "hidden"
83
83
  dropdownTrigger: "auto", // "auto" | "manual" | "hidden"
84
84
  closeDropdownOnBlur: true, // false = keep dropdown open even when input loses focus
85
+ showNonTappableOptions: true, // false = hide non-tappable options from the dropdown
85
86
 
86
87
  // Focus
87
88
  autoFocus: true, // focus the input on mount (Tier 1 only)
@@ -201,7 +202,9 @@ const state = ac.getState();
201
202
  // state.placeholderText, state.isDropdownOpen, state.activeDropdownIndex,
202
203
  // state.newParamId, state.isLoading, state.isReady, state.error
203
204
 
204
- // Subscribe to changes (fires after derived state is settled)
205
+ // Subscribe to state changes. The listener receives the full state shape
206
+ // (raw inputs + derived fields like `segments`, `actionableSuggestions`,
207
+ // `filteredOptions`, `placeholderText`, `isDropdownOpen`).
205
208
  const unsub = ac.subscribe((state) => {
206
209
  renderMyInput(state.text, state.placeholderText);
207
210
  renderMyPills(state.actionableSuggestions);
@@ -260,7 +263,7 @@ unsub();
260
263
  | `setMode(mode)` | Switch color mode at runtime. |
261
264
  | `update(opts)` | Update multiple options at once (e.g. `animations`, `pillPlacement`, `dropdownTrigger`, `optionsPosition`). |
262
265
  | `getState()` | Get a snapshot of the current state. Useful for initial read before subscribing. |
263
- | `subscribe(listener)` | Subscribe to state changes (fires after derived state settles). Returns unsubscribe function. |
266
+ | `subscribe(listener)` | Subscribe to state changes. The listener receives the full state (raw inputs + derived fields). Returns unsubscribe function. |
264
267
  | `destroy()` | Cleanup — abort fetches, clear timers, remove listeners. |
265
268
 
266
269
  ### Building a framework wrapper
@@ -324,13 +327,18 @@ The object passed to `onSubmit`:
324
327
  ```ts
325
328
  const off = ac.on("submit", (result) => { ... });
326
329
  off(); // unsubscribe
330
+
331
+ // Multiple listeners on the same event are supported — each `on()` call adds
332
+ // a listener; the returned function removes only the listener it registered.
333
+ const offA = ac.on("change", logToAnalytics);
334
+ const offB = ac.on("change", syncToStore);
327
335
  ```
328
336
 
329
- Events: `submit`, `error`, `change`, `paramsChange`, `focus`, `blur`.
337
+ Events: `submit`, `error`, `change`, `paramsChange`, `stateChange`, `focus`, `blur`.
330
338
 
331
- Constructor callbacks (`onSubmit`, `onChange`, etc.) are equivalent to calling `on()`.
339
+ 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.
332
340
 
333
- **`subscribe()` vs `on()`:** Use `on()` for specific events (submit, error, text change). Use `subscribe()` (Tier 3) for full state updates — it fires after derived state is settled, making it safe for rendering.
341
+ **`subscribe()` vs `on()`:** Use `on()` for specific events (submit, error, text change). Use `subscribe()` (Tier 3) for full state updates — every `subscribe` callback receives the merged input + derived state, ready to read.
334
342
 
335
343
  ---
336
344
 
package/index.d.mts CHANGED
@@ -67,8 +67,11 @@ interface AutocompleteResult {
67
67
  completed_params: CompletedParam[];
68
68
  }
69
69
 
70
- /** Internal state managed by the store. */
71
- interface CoreState {
70
+ /**
71
+ * Raw, user/network/internal-driven fields. The store holds *only* these —
72
+ * derived state is recomputed lazily on read. See {@link CoreDerivedState}.
73
+ */
74
+ interface CoreInputState {
72
75
  text: string;
73
76
  completedParams: CompletedParamState[];
74
77
  suggestions: Suggestion[];
@@ -77,11 +80,6 @@ interface CoreState {
77
80
  isLoading: boolean;
78
81
  isReady: boolean;
79
82
  error: Error | null;
80
- segments: Segment[];
81
- actionableSuggestions: Suggestion[];
82
- filteredOptions: SuggestionOption[];
83
- placeholderText: string;
84
- isDropdownOpen: boolean;
85
83
  filterBase: number;
86
84
  filterInProgress: boolean;
87
85
  pillTapped: boolean;
@@ -104,10 +102,27 @@ interface CoreState {
104
102
  /**
105
103
  * True for ~500ms after a user-initiated option selection so the streak
106
104
  * animation can finish before the dropdown switches to its loading skeleton.
107
- * Set by selectOption / selectOptionInEditMode, cleared by a timer.
105
+ * Set by selectOption / ReEditManager.selectOption, cleared by a timer.
108
106
  */
109
107
  inSelectionAnimation: boolean;
110
108
  }
109
+ /**
110
+ * Derived fields recomputed from {@link CoreInputState} + {@link CoreOptions}.
111
+ * Never stored — produced lazily by the derive function and merged onto
112
+ * `get()` reads / subscriber notifications.
113
+ */
114
+ interface CoreDerivedState {
115
+ segments: Segment[];
116
+ actionableSuggestions: Suggestion[];
117
+ filteredOptions: SuggestionOption[];
118
+ placeholderText: string;
119
+ isDropdownOpen: boolean;
120
+ }
121
+ /**
122
+ * Full state shape (inputs + derived) exposed to consumers via `getState()`
123
+ * and subscribers.
124
+ */
125
+ type CoreState = CoreInputState & CoreDerivedState;
111
126
  /**
112
127
  * Render mode:
113
128
  * - "full" (default) — renders input + dropdown + pills. Tier 1.
@@ -130,6 +145,8 @@ interface CoreOptions {
130
145
  dropdownTrigger?: "auto" | "manual" | "hidden";
131
146
  /** When true (default), the dropdown closes when the input loses focus. Set to false to keep it open whenever options are available, regardless of focus. */
132
147
  closeDropdownOnBlur?: boolean;
148
+ /** When true (default), non-tappable options are rendered in the dropdown alongside tappable ones. Set to false to hide them entirely. */
149
+ showNonTappableOptions?: boolean;
133
150
  /** Render mode. Default: "full". */
134
151
  renderMode?: RenderMode;
135
152
  /**
@@ -168,25 +185,36 @@ interface CoreOptions {
168
185
  setCursor?: (offset: number) => void;
169
186
  }
170
187
 
188
+ type AIAutocompleteEvents = {
189
+ submit: [result: AutocompleteResult];
190
+ error: [error: Error];
191
+ change: [text: string];
192
+ paramsChange: [params: CompletedParamState[]];
193
+ stateChange: [state: CoreState];
194
+ focus: [];
195
+ blur: [];
196
+ };
197
+ type CoreUpdateOptions = Partial<Omit<CoreOptions, "onSubmit" | "onError" | "onChange" | "onParamsChange" | "onStateChange" | "onFocus" | "onBlur">>;
171
198
  declare class AIAutocomplete {
199
+ private inputStore;
172
200
  private store;
173
201
  private _listboxId;
174
202
  private opts;
175
203
  private fetchController;
176
204
  private keyboardController;
177
205
  private pillsController;
206
+ private reEdit;
178
207
  private modeController;
179
208
  private container;
180
209
  private unsubscribers;
181
- private derivedInProgress;
182
210
  private renderMode;
183
211
  private domRefs;
184
212
  private dropdownRefs;
185
- private newParamTimer;
186
- private suggestionRemovalTimer;
187
- private selectionAnimationTimer;
188
- private externalListeners;
213
+ private timers;
214
+ private emitter;
189
215
  private sessionId;
216
+ private readonly emitSubmit;
217
+ private readonly emitError;
190
218
  constructor(container: HTMLElement, opts?: CoreOptions);
191
219
  focus(): void;
192
220
  blur(): void;
@@ -211,56 +239,41 @@ declare class AIAutocomplete {
211
239
  * Set the editor caret at the given plain-text offset. Uses the core's own
212
240
  * `domRefs.input` in "full" mode; falls back to the wrapper-provided
213
241
  * `setCursor` callback in "headless" mode where the wrapper owns the DOM.
242
+ *
243
+ * Also focuses the input — setting a selection range without focus leaves a
244
+ * visible caret that doesn't actually accept typing, and every caller (pill
245
+ * tap, post-promote refocus, re-edit entry, Backspace-into-param) expects
246
+ * the editor to be focused afterwards.
214
247
  */
215
248
  private scheduleSetCursor;
216
249
  clearNewParamId(): void;
217
- /**
218
- * Enter re-edit mode for the bold completed param with the given id. The
219
- * caller is responsible for highlighting the param via the Selection API
220
- * (DOM concern). This method just sets the state snapshot so the dropdown
221
- * starts filtering the param's cached options.
222
- */
223
250
  startEditingParam(paramId: string): void;
224
- /**
225
- * Replace the editing range (`text[editingAnchor..editingTail]`) with the
226
- * given replacement. Used by the DOM layer's `beforeinput` intercept: while
227
- * the edited param's strong still exists in `completedParams`, typing or
228
- * backspace should swap the WHOLE param atomically (instead of inserting
229
- * one char into the bold span). Returns true when the replacement was
230
- * applied — callers should `preventDefault` only in that case.
231
- */
232
251
  replaceEditingRange(replacement: string): boolean;
233
- /** Clear re-edit state. Selection collapsing is a DOM concern handled by the caller. */
234
252
  exitEditMode(): void;
235
- /**
236
- * Update caret offset after a text-input event. While in edit mode, extends
237
- * `editingTail` to keep up with the user typing past the previous tail.
238
- * Exits edit mode if the user backspaced past `editingAnchor` (deleting a
239
- * character before where the edit started).
240
- */
241
253
  handleCaretAfterInput(offset: number | null): void;
242
- /**
243
- * Update caret offset from a selection-move event that was NOT preceded by
244
- * an input event (so the user moved the cursor by click or arrows, not by
245
- * typing). Exits edit mode if the caret moved outside the edit region.
246
- */
247
254
  handleCaretMove(offset: number | null): void;
248
255
  setActiveDropdownIndex(index: number): void;
249
256
  handleTextChange(value: string): void;
250
257
  handleKeyDown(e: KeyboardEvent): void;
251
258
  setFocused(focused: boolean): void;
252
- /** Subscribe to state changes (fires after derived state is settled). Returns unsubscribe function. */
259
+ /** Subscribe to state changes. Listener receives the full (input + derived) shape. */
253
260
  subscribe(listener: (state: CoreState) => void): () => void;
254
261
  getState(): CoreState;
255
262
  get listboxId(): string;
256
263
  get isReady(): boolean;
257
- on(event: string, callback: (...args: unknown[]) => void): () => void;
258
- update(opts: Partial<CoreOptions>): void;
264
+ /**
265
+ * Subscribe to an event. Multiple listeners may register for the same event;
266
+ * `emit` fans out to all of them. The returned function removes only the
267
+ * listener it registered.
268
+ *
269
+ * Note: `opts.on*` listeners passed at construction are equivalent to calling
270
+ * `on()` once each; their unsubscribe handles are not exposed.
271
+ */
272
+ on<E extends keyof AIAutocompleteEvents>(event: E, callback: (...args: AIAutocompleteEvents[E]) => void): () => void;
273
+ update(opts: CoreUpdateOptions): void;
259
274
  selectOption(option: SuggestionOption): void;
260
275
  private startSelectionAnimationTimer;
261
- private selectOptionInEditMode;
262
276
  private fireTelemetry;
263
- private recomputeDerived;
264
277
  private setupContainer;
265
278
  private buildAndRenderFull;
266
279
  private buildAndRenderDropdown;
@@ -269,6 +282,17 @@ declare class AIAutocomplete {
269
282
  /** Auto-clear newParamId after shimmer animation. */
270
283
  private subscribeNewParamTimer;
271
284
  private handleChange;
285
+ /**
286
+ * In re-edit mode, once the user has typed enough that no *tappable* options
287
+ * still match (non-tappable options are kept by filterOptions regardless of
288
+ * the query, so they don't count as "still matching"), exit re-edit and
289
+ * fire an immediate fetch so the dropdown swaps over to fresh server
290
+ * suggestions instead of staying frozen on a dead filter.
291
+ *
292
+ * Guarded against re-entry: once we exit, editingParam is null and the
293
+ * subscription early-returns on subsequent fires.
294
+ */
295
+ private maybeExitReEditOnNoMatch;
272
296
  /**
273
297
  * When the user has typed text that exactly matches (case-insensitive) one
274
298
  * of the active suggestion's options, promote it to a completed param right
@@ -277,14 +301,6 @@ declare class AIAutocomplete {
277
301
  * option is fully typed, without waiting 100–300ms for the round-trip.
278
302
  */
279
303
  private maybePromoteExactMatch;
280
- /**
281
- * Edit-mode counterpart to {@link maybePromoteExactMatch}. When the user is
282
- * editing a bold completed param and the typed text exactly matches one of
283
- * that param's cached options, promote it back to a completed param (bold)
284
- * — same instant-bold affordance as the normal typing flow, just driven by
285
- * the edited param's cached options instead of the active suggestion.
286
- */
287
- private maybePromoteEditExactMatch;
288
304
  }
289
305
 
290
306
  type Listener<S> = (next: S, prev: S) => void;
package/index.d.ts CHANGED
@@ -67,8 +67,11 @@ interface AutocompleteResult {
67
67
  completed_params: CompletedParam[];
68
68
  }
69
69
 
70
- /** Internal state managed by the store. */
71
- interface CoreState {
70
+ /**
71
+ * Raw, user/network/internal-driven fields. The store holds *only* these —
72
+ * derived state is recomputed lazily on read. See {@link CoreDerivedState}.
73
+ */
74
+ interface CoreInputState {
72
75
  text: string;
73
76
  completedParams: CompletedParamState[];
74
77
  suggestions: Suggestion[];
@@ -77,11 +80,6 @@ interface CoreState {
77
80
  isLoading: boolean;
78
81
  isReady: boolean;
79
82
  error: Error | null;
80
- segments: Segment[];
81
- actionableSuggestions: Suggestion[];
82
- filteredOptions: SuggestionOption[];
83
- placeholderText: string;
84
- isDropdownOpen: boolean;
85
83
  filterBase: number;
86
84
  filterInProgress: boolean;
87
85
  pillTapped: boolean;
@@ -104,10 +102,27 @@ interface CoreState {
104
102
  /**
105
103
  * True for ~500ms after a user-initiated option selection so the streak
106
104
  * animation can finish before the dropdown switches to its loading skeleton.
107
- * Set by selectOption / selectOptionInEditMode, cleared by a timer.
105
+ * Set by selectOption / ReEditManager.selectOption, cleared by a timer.
108
106
  */
109
107
  inSelectionAnimation: boolean;
110
108
  }
109
+ /**
110
+ * Derived fields recomputed from {@link CoreInputState} + {@link CoreOptions}.
111
+ * Never stored — produced lazily by the derive function and merged onto
112
+ * `get()` reads / subscriber notifications.
113
+ */
114
+ interface CoreDerivedState {
115
+ segments: Segment[];
116
+ actionableSuggestions: Suggestion[];
117
+ filteredOptions: SuggestionOption[];
118
+ placeholderText: string;
119
+ isDropdownOpen: boolean;
120
+ }
121
+ /**
122
+ * Full state shape (inputs + derived) exposed to consumers via `getState()`
123
+ * and subscribers.
124
+ */
125
+ type CoreState = CoreInputState & CoreDerivedState;
111
126
  /**
112
127
  * Render mode:
113
128
  * - "full" (default) — renders input + dropdown + pills. Tier 1.
@@ -130,6 +145,8 @@ interface CoreOptions {
130
145
  dropdownTrigger?: "auto" | "manual" | "hidden";
131
146
  /** When true (default), the dropdown closes when the input loses focus. Set to false to keep it open whenever options are available, regardless of focus. */
132
147
  closeDropdownOnBlur?: boolean;
148
+ /** When true (default), non-tappable options are rendered in the dropdown alongside tappable ones. Set to false to hide them entirely. */
149
+ showNonTappableOptions?: boolean;
133
150
  /** Render mode. Default: "full". */
134
151
  renderMode?: RenderMode;
135
152
  /**
@@ -168,25 +185,36 @@ interface CoreOptions {
168
185
  setCursor?: (offset: number) => void;
169
186
  }
170
187
 
188
+ type AIAutocompleteEvents = {
189
+ submit: [result: AutocompleteResult];
190
+ error: [error: Error];
191
+ change: [text: string];
192
+ paramsChange: [params: CompletedParamState[]];
193
+ stateChange: [state: CoreState];
194
+ focus: [];
195
+ blur: [];
196
+ };
197
+ type CoreUpdateOptions = Partial<Omit<CoreOptions, "onSubmit" | "onError" | "onChange" | "onParamsChange" | "onStateChange" | "onFocus" | "onBlur">>;
171
198
  declare class AIAutocomplete {
199
+ private inputStore;
172
200
  private store;
173
201
  private _listboxId;
174
202
  private opts;
175
203
  private fetchController;
176
204
  private keyboardController;
177
205
  private pillsController;
206
+ private reEdit;
178
207
  private modeController;
179
208
  private container;
180
209
  private unsubscribers;
181
- private derivedInProgress;
182
210
  private renderMode;
183
211
  private domRefs;
184
212
  private dropdownRefs;
185
- private newParamTimer;
186
- private suggestionRemovalTimer;
187
- private selectionAnimationTimer;
188
- private externalListeners;
213
+ private timers;
214
+ private emitter;
189
215
  private sessionId;
216
+ private readonly emitSubmit;
217
+ private readonly emitError;
190
218
  constructor(container: HTMLElement, opts?: CoreOptions);
191
219
  focus(): void;
192
220
  blur(): void;
@@ -211,56 +239,41 @@ declare class AIAutocomplete {
211
239
  * Set the editor caret at the given plain-text offset. Uses the core's own
212
240
  * `domRefs.input` in "full" mode; falls back to the wrapper-provided
213
241
  * `setCursor` callback in "headless" mode where the wrapper owns the DOM.
242
+ *
243
+ * Also focuses the input — setting a selection range without focus leaves a
244
+ * visible caret that doesn't actually accept typing, and every caller (pill
245
+ * tap, post-promote refocus, re-edit entry, Backspace-into-param) expects
246
+ * the editor to be focused afterwards.
214
247
  */
215
248
  private scheduleSetCursor;
216
249
  clearNewParamId(): void;
217
- /**
218
- * Enter re-edit mode for the bold completed param with the given id. The
219
- * caller is responsible for highlighting the param via the Selection API
220
- * (DOM concern). This method just sets the state snapshot so the dropdown
221
- * starts filtering the param's cached options.
222
- */
223
250
  startEditingParam(paramId: string): void;
224
- /**
225
- * Replace the editing range (`text[editingAnchor..editingTail]`) with the
226
- * given replacement. Used by the DOM layer's `beforeinput` intercept: while
227
- * the edited param's strong still exists in `completedParams`, typing or
228
- * backspace should swap the WHOLE param atomically (instead of inserting
229
- * one char into the bold span). Returns true when the replacement was
230
- * applied — callers should `preventDefault` only in that case.
231
- */
232
251
  replaceEditingRange(replacement: string): boolean;
233
- /** Clear re-edit state. Selection collapsing is a DOM concern handled by the caller. */
234
252
  exitEditMode(): void;
235
- /**
236
- * Update caret offset after a text-input event. While in edit mode, extends
237
- * `editingTail` to keep up with the user typing past the previous tail.
238
- * Exits edit mode if the user backspaced past `editingAnchor` (deleting a
239
- * character before where the edit started).
240
- */
241
253
  handleCaretAfterInput(offset: number | null): void;
242
- /**
243
- * Update caret offset from a selection-move event that was NOT preceded by
244
- * an input event (so the user moved the cursor by click or arrows, not by
245
- * typing). Exits edit mode if the caret moved outside the edit region.
246
- */
247
254
  handleCaretMove(offset: number | null): void;
248
255
  setActiveDropdownIndex(index: number): void;
249
256
  handleTextChange(value: string): void;
250
257
  handleKeyDown(e: KeyboardEvent): void;
251
258
  setFocused(focused: boolean): void;
252
- /** Subscribe to state changes (fires after derived state is settled). Returns unsubscribe function. */
259
+ /** Subscribe to state changes. Listener receives the full (input + derived) shape. */
253
260
  subscribe(listener: (state: CoreState) => void): () => void;
254
261
  getState(): CoreState;
255
262
  get listboxId(): string;
256
263
  get isReady(): boolean;
257
- on(event: string, callback: (...args: unknown[]) => void): () => void;
258
- update(opts: Partial<CoreOptions>): void;
264
+ /**
265
+ * Subscribe to an event. Multiple listeners may register for the same event;
266
+ * `emit` fans out to all of them. The returned function removes only the
267
+ * listener it registered.
268
+ *
269
+ * Note: `opts.on*` listeners passed at construction are equivalent to calling
270
+ * `on()` once each; their unsubscribe handles are not exposed.
271
+ */
272
+ on<E extends keyof AIAutocompleteEvents>(event: E, callback: (...args: AIAutocompleteEvents[E]) => void): () => void;
273
+ update(opts: CoreUpdateOptions): void;
259
274
  selectOption(option: SuggestionOption): void;
260
275
  private startSelectionAnimationTimer;
261
- private selectOptionInEditMode;
262
276
  private fireTelemetry;
263
- private recomputeDerived;
264
277
  private setupContainer;
265
278
  private buildAndRenderFull;
266
279
  private buildAndRenderDropdown;
@@ -269,6 +282,17 @@ declare class AIAutocomplete {
269
282
  /** Auto-clear newParamId after shimmer animation. */
270
283
  private subscribeNewParamTimer;
271
284
  private handleChange;
285
+ /**
286
+ * In re-edit mode, once the user has typed enough that no *tappable* options
287
+ * still match (non-tappable options are kept by filterOptions regardless of
288
+ * the query, so they don't count as "still matching"), exit re-edit and
289
+ * fire an immediate fetch so the dropdown swaps over to fresh server
290
+ * suggestions instead of staying frozen on a dead filter.
291
+ *
292
+ * Guarded against re-entry: once we exit, editingParam is null and the
293
+ * subscription early-returns on subsequent fires.
294
+ */
295
+ private maybeExitReEditOnNoMatch;
272
296
  /**
273
297
  * When the user has typed text that exactly matches (case-insensitive) one
274
298
  * of the active suggestion's options, promote it to a completed param right
@@ -277,14 +301,6 @@ declare class AIAutocomplete {
277
301
  * option is fully typed, without waiting 100–300ms for the round-trip.
278
302
  */
279
303
  private maybePromoteExactMatch;
280
- /**
281
- * Edit-mode counterpart to {@link maybePromoteExactMatch}. When the user is
282
- * editing a bold completed param and the typed text exactly matches one of
283
- * that param's cached options, promote it back to a completed param (bold)
284
- * — same instant-bold affordance as the normal typing flow, just driven by
285
- * the edited param's cached options instead of the active suggestion.
286
- */
287
- private maybePromoteEditExactMatch;
288
304
  }
289
305
 
290
306
  type Listener<S> = (next: S, prev: S) => void;