@energysage/es-ds-components 6.2.0 → 6.3.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.
@@ -0,0 +1,386 @@
1
+ import type { Ref } from 'vue';
2
+ import { computed, nextTick, ref, watch } from 'vue';
3
+ import type { EsAutocompleteSuggestion } from '../types';
4
+
5
+ interface AutocompleteComboboxOptions {
6
+ /**
7
+ * APG's automatic selection: the first suggestion stands selected as the list
8
+ * arrives, and becomes the value when focus leaves
9
+ */
10
+ autoSelect: () => boolean;
11
+ /**
12
+ * close this shell's panel/takeover. a selection passes its text, so a shell
13
+ * that reads the DOM mid-close (the takeover's exit ghost) is not stale.
14
+ */
15
+ close: (selectedText?: string) => void;
16
+ emitSelect: (suggestion: EsAutocompleteSuggestion) => void;
17
+ /** unique per shell instance: option ids derive from it */
18
+ idPrefix: string;
19
+ inputEl: Ref<HTMLInputElement | null>;
20
+ model: Ref<string>;
21
+ /**
22
+ * whether leaving the field takes automatic selection's choice as the value.
23
+ * a field holding a value wants that; one that acts on a choice — a search
24
+ * that navigates — would be acting on merely having been left
25
+ */
26
+ selectOnBlur: () => boolean;
27
+ /**
28
+ * the suggestions the arrows navigate: the displayed list, after the cap and
29
+ * the trim, and empty while closed so keys cannot walk an invisible list
30
+ */
31
+ suggestions: () => EsAutocompleteSuggestion[];
32
+ }
33
+
34
+ /**
35
+ * the combobox core both shells share: highlight state, aria-activedescendant
36
+ * over non-focusable options, cyclic arrows, copy-on-highlight, and Enter's
37
+ * select-or-stand-aside. value and activedescendant come from one index, so they
38
+ * land together, which is what VoiceOver stays anchored for.
39
+ */
40
+ export function useEsAutocompleteCombobox(options: AutocompleteComboboxOptions) {
41
+ // -1 is the input itself (no option highlighted)
42
+ const highlightIndex = ref(-1);
43
+ // only a highlight the user created makes Enter select, and only keyboard
44
+ // navigation mirrors into the input: hovering must not change the field
45
+ const highlightSource = ref<'keyboard' | 'pointer' | null>(null);
46
+
47
+ // drives the focus-visible ring on the highlighted suggestion: keyboard
48
+ // navigation shows it, hovering shows only the background shading (the same
49
+ // split es-dropdown-select makes)
50
+ const keyboardNav = computed(() => highlightSource.value === 'keyboard');
51
+
52
+ // the suggestion the field itself follows under automatic selection: the
53
+ // first, and only arrowing takes the field off it. a pointer passing over the
54
+ // list highlights what it is over without touching what the field proposes
55
+ const autoSelectedSuggestion = computed(() =>
56
+ options.autoSelect() && !keyboardNav.value ? (options.suggestions()[0] ?? null) : null,
57
+ );
58
+ // automatic selection also holds the highlight, until the user takes it
59
+ const autoHighlighted = computed(() => autoSelectedSuggestion.value !== null && highlightIndex.value < 0);
60
+ // where the highlight actually sits: the user's, or automatic selection's
61
+ const selectedIndex = computed(() => (autoHighlighted.value ? 0 : highlightIndex.value));
62
+
63
+ const highlighted = computed(() => options.suggestions()[selectedIndex.value] ?? null);
64
+
65
+ // while a keyboard-highlighted suggestion carries the focus-visible ring,
66
+ // the field hides its own ring — the indicator moves with the navigation
67
+ const keyboardHighlightActive = computed(() => keyboardNav.value && highlightIndex.value >= 0);
68
+
69
+ // a deletion must not be answered with a completion putting the character
70
+ // back, so the edit's shape decides whether the next list may complete
71
+ const completionAllowed = ref(true);
72
+
73
+ // automatic selection's inline completion: the selected suggestion continues
74
+ // the typed text, so the untyped remainder can stand in the field as a
75
+ // proposal. a suggestion matching some other way is selected but not written.
76
+ //
77
+ // only that remainder is added. the typed characters stay exactly as they
78
+ // were typed, so a capital in the suggestion never rewrites a keystroke —
79
+ // the chosen suggestion's own text becomes the value on selection.
80
+ const autoCompletion = computed(() => {
81
+ const typed = options.model.value;
82
+ const suggestion = autoSelectedSuggestion.value;
83
+ if (!completionAllowed.value || !typed || !suggestion || suggestion.text.length <= typed.length) {
84
+ return null;
85
+ }
86
+ return suggestion.text.toLowerCase().startsWith(typed.toLowerCase())
87
+ ? typed + suggestion.text.slice(typed.length)
88
+ : null;
89
+ });
90
+
91
+ // what the input displays: the typed query, the highlighted suggestion while
92
+ // arrowing, or the inline completion. either mirror lets users see what
93
+ // selecting would enter while the app sees no query change — no 'complete'
94
+ // fires and the bolding stays keyed to it
95
+ const displayValue = computed(() => {
96
+ if (keyboardNav.value && highlighted.value) {
97
+ return highlighted.value.text;
98
+ }
99
+ return autoCompletion.value ?? options.model.value;
100
+ });
101
+
102
+ // what automatic selection has chosen, for the shells to announce. a
103
+ // suggestion that does not complete the typed text changes nothing on screen,
104
+ // and an aria-activedescendant change alone is not reliably spoken, so
105
+ // without this a screen reader hears no selection at all
106
+ const autoSelectedText = computed(() => (options.autoSelect() ? (options.suggestions()[0]?.text ?? '') : ''));
107
+
108
+ const listboxId = `${options.idPrefix}-listbox`;
109
+ function optionId(index: number) {
110
+ return `${options.idPrefix}-option-${index}`;
111
+ }
112
+ // the user's own highlight only, never automatic selection's: VoiceOver takes
113
+ // its cursor to whatever this names, and a cursor parked on an option is no
114
+ // longer in a text field — single keys stop typing and become quick-nav
115
+ // commands. automatic selection says what it chose through aria-selected on
116
+ // the option, the completion in the field, and the shell's live region.
117
+ const activeDescendant = computed(() => (highlightIndex.value >= 0 ? optionId(highlightIndex.value) : undefined));
118
+
119
+ function resetHighlight() {
120
+ highlightIndex.value = -1;
121
+ highlightSource.value = null;
122
+ }
123
+
124
+ // a different list means the old index would highlight an arbitrary other
125
+ // suggestion; the reset also restores the typed text via displayValue
126
+ watch(
127
+ () => {
128
+ return options
129
+ .suggestions()
130
+ .map((suggestion) => suggestion.id)
131
+ .join('\n');
132
+ },
133
+ () => resetHighlight(),
134
+ );
135
+
136
+ // typing means editing the query, not navigating, and it applies to whatever
137
+ // the field shows — a mirrored suggestion is editable, as on Google
138
+ function onInput(event: Event) {
139
+ const { inputType, isComposing } = event as InputEvent;
140
+ completionAllowed.value = !isComposing && !inputType?.startsWith('delete');
141
+ const value = (event.target as HTMLInputElement).value;
142
+ highlightIndex.value = -1;
143
+ highlightSource.value = null;
144
+ options.model.value = value;
145
+ }
146
+
147
+ function moveHighlight(step: 1 | -1) {
148
+ const count = options.suggestions().length;
149
+ if (!count) {
150
+ return;
151
+ }
152
+ // automatic selection's first suggestion is where the arrows start from
153
+ const from = selectedIndex.value;
154
+ // fully cyclic, with -1 (the input) as a stop: input → first → … → last →
155
+ // input → … and the inverse going up
156
+ const range = count + 1;
157
+ const next = ((from + 1 + step + range) % range) - 1;
158
+ highlightIndex.value = next;
159
+ // back at the input the user holds no highlight, which hands the field to
160
+ // automatic selection again rather than leaving it on the typed text
161
+ highlightSource.value = next < 0 ? null : 'keyboard';
162
+ markValueRewrite();
163
+ void revealCaretAtEnd();
164
+ }
165
+
166
+ // arrowing onto a suggestion copies it into the field, and automatic selection
167
+ // completes what was typed: both write the value from script. VoiceOver answers
168
+ // such a write by moving its cursor off the input, which fires a focusout that
169
+ // looks exactly like the user leaving — and taking it as one would close the
170
+ // panel mid-navigation. so the component notes the time of each write, and
171
+ // counts a collapse that follows within this window as that echo, not a
172
+ // departure.
173
+ const REWRITE_BLUR_WINDOW_MS = 250;
174
+ let rewrittenAt = 0;
175
+ function markValueRewrite() {
176
+ rewrittenAt = performance.now();
177
+ }
178
+ // whether the collapse that just happened is one of those echoes. reading
179
+ // forgets the write, so one write accounts for one collapse and a later one is
180
+ // the user. a time rather than a flag, which would stay set with no echo to
181
+ // answer it and go on to mistake a real departure for one.
182
+ function consumeRewriteBlur() {
183
+ const at = rewrittenAt;
184
+ rewrittenAt = 0;
185
+ return at !== 0 && performance.now() - at < REWRITE_BLUR_WINDOW_MS;
186
+ }
187
+
188
+ // the completion's untyped remainder stays selected, so the next keystroke
189
+ // replaces it and Backspace removes it: the field reads as the typed text
190
+ // with a proposal after it, which is what makes the proposal refusable.
191
+ //
192
+ // re-applied on every edit, not only when the completion changes: typing
193
+ // over the remainder leaves the same suggestion completing the longer text,
194
+ // and writing that value back to the input puts the caret at its end.
195
+ watch(
196
+ [autoCompletion, () => options.model.value],
197
+ ([completion, typed], [previousCompletion]) => {
198
+ const el = options.inputEl.value;
199
+ if (completion === null || !el || document.activeElement !== el) {
200
+ return;
201
+ }
202
+ // only a completion arriving rewrites the field under the user;
203
+ // holding the remainder selected as they type is their own edit, and
204
+ // claiming it would answer every keystroke's blur by taking focus back
205
+ if (completion !== previousCompletion) {
206
+ markValueRewrite();
207
+ }
208
+ el.setSelectionRange(typed.length, el.value.length);
209
+ },
210
+ // after the render patch that writes the input's DOM value, so the
211
+ // selection is not the one the write discards
212
+ { flush: 'post' },
213
+ );
214
+
215
+ // APG's automatic selection: what the field already shows selected becomes
216
+ // the value when focus leaves, unless the user typed something else. that is
217
+ // a suggestion they arrowed to where they did, otherwise the one automatic
218
+ // selection holds; a pointer resting over a row is not a choice, so it
219
+ // leaves the held one to be taken.
220
+ function commitAutoSelection() {
221
+ if (!options.autoSelect() || !options.selectOnBlur()) {
222
+ return false;
223
+ }
224
+ const suggestion = keyboardNav.value ? highlighted.value : autoSelectedSuggestion.value;
225
+ if (!suggestion) {
226
+ return false;
227
+ }
228
+ select(suggestion);
229
+ return true;
230
+ }
231
+
232
+ function onKeydown(event: KeyboardEvent) {
233
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
234
+ // keep the caret from jumping to the text's start/end
235
+ event.preventDefault();
236
+ moveHighlight(event.key === 'ArrowDown' ? 1 : -1);
237
+ return;
238
+ }
239
+ if (event.key === 'Enter') {
240
+ // the Enter that commits an IME composition (Japanese/Chinese/Korean
241
+ // input) chooses nothing
242
+ if (event.isComposing) {
243
+ return;
244
+ }
245
+ if ((highlightSource.value !== null || autoHighlighted.value) && highlighted.value) {
246
+ // choosing the highlighted suggestion is what this key does here,
247
+ // so a surrounding form must not also submit on it
248
+ event.preventDefault();
249
+ select(highlighted.value);
250
+ return;
251
+ }
252
+ // nothing is chosen, so the key is not ours: it reaches the page as
253
+ // from any other field, and a form submits implicitly. the panel
254
+ // closes because the query is committed.
255
+ options.close();
256
+ }
257
+ }
258
+
259
+ function select(suggestion: EsAutocompleteSuggestion) {
260
+ options.model.value = suggestion.text;
261
+ resetHighlight();
262
+ options.close(suggestion.text);
263
+ options.emitSelect(suggestion);
264
+ void revealCaretAtEnd();
265
+ }
266
+
267
+ function onOptionClick(index: number) {
268
+ const suggestion = options.suggestions()[index];
269
+ if (suggestion) {
270
+ select(suggestion);
271
+ }
272
+ }
273
+
274
+ // the pointer takes the highlight only when the arrows are not holding it: a
275
+ // panel opens under a resting cursor, where the smallest twitch would undo
276
+ // what was navigated to and take a screen reader's cursor with it. a click
277
+ // still chooses the row under the pointer, whichever one is highlighted.
278
+ function onOptionPointermove(index: number) {
279
+ if (keyboardNav.value) {
280
+ return;
281
+ }
282
+ if (highlightIndex.value !== index || highlightSource.value !== 'pointer') {
283
+ highlightIndex.value = index;
284
+ highlightSource.value = 'pointer';
285
+ }
286
+ }
287
+
288
+ // keep the input focused while clicking in the panel, so focus and the caret
289
+ // are still there after selecting. interactive elements in a consumer's item
290
+ // slot are exempt, so they stay focusable.
291
+ function onListMousedown(event: MouseEvent) {
292
+ const target = event.target as HTMLElement | null;
293
+ const interactive =
294
+ 'a[href], button, input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
295
+ if (!target?.closest(interactive)) {
296
+ event.preventDefault();
297
+ }
298
+ }
299
+
300
+ function onClear() {
301
+ options.model.value = '';
302
+ resetHighlight();
303
+ options.inputEl.value?.focus();
304
+ }
305
+
306
+ // browsers scroll the caret into view when the selection changes, never on
307
+ // focus itself, so a refocused field shows its start with the restored caret
308
+ // out of view. measure where that caret is and bring it in.
309
+ let caretContext: CanvasRenderingContext2D | null | undefined;
310
+ function revealCaretOnFocus() {
311
+ // deferred a frame so the browser's own focus handling (caret restore,
312
+ // a click's caret placement, Tab's select-all) settles first
313
+ requestAnimationFrame(() => {
314
+ const el = options.inputEl.value;
315
+ if (!el || document.activeElement !== el || el.scrollWidth <= el.clientWidth) {
316
+ return;
317
+ }
318
+ const caret = el.selectionStart;
319
+ if (caret === null || caret !== el.selectionEnd) {
320
+ return;
321
+ }
322
+ const context = (caretContext ??= document.createElement('canvas').getContext('2d'));
323
+ if (!context) {
324
+ return;
325
+ }
326
+ const style = getComputedStyle(el);
327
+ context.font = style.font || `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
328
+ const caretX = context.measureText(el.value.slice(0, caret)).width;
329
+ const viewWidth =
330
+ el.clientWidth -
331
+ (Number.parseFloat(style.paddingLeft) || 0) -
332
+ (Number.parseFloat(style.paddingRight) || 0);
333
+ if (caretX < el.scrollLeft) {
334
+ el.scrollLeft = caretX;
335
+ } else if (caretX > el.scrollLeft + viewWidth) {
336
+ el.scrollLeft = caretX - viewWidth;
337
+ }
338
+ });
339
+ }
340
+
341
+ // text written programmatically leaves the caret at the end but the field
342
+ // scrolled to the start, so a value wider than the field shows its beginning.
343
+ // reveal the caret once the written value has reached the DOM.
344
+ async function revealCaretAtEnd() {
345
+ // two ticks: the first flushes the ref watchers, the second the render
346
+ // patch that writes the input's DOM value
347
+ await nextTick();
348
+ await nextTick();
349
+ const el = options.inputEl.value;
350
+ if (!el || document.activeElement !== el) {
351
+ return;
352
+ }
353
+ // arrowing back to the input hands the field to automatic selection,
354
+ // whose completion holds its remainder selected: a move still in flight
355
+ // from an earlier press would otherwise land on top and collapse it
356
+ if (autoCompletion.value !== null) {
357
+ return;
358
+ }
359
+ const end = el.value.length;
360
+ el.setSelectionRange(end, end);
361
+ el.scrollLeft = el.scrollWidth;
362
+ }
363
+
364
+ return {
365
+ activeDescendant,
366
+ autoSelectedText,
367
+ commitAutoSelection,
368
+ consumeRewriteBlur,
369
+ displayValue,
370
+ keyboardHighlightActive,
371
+ keyboardNav,
372
+ listboxId,
373
+ onClear,
374
+ onInput,
375
+ onKeydown,
376
+ onListMousedown,
377
+ onOptionClick,
378
+ onOptionPointermove,
379
+ optionId,
380
+ resetHighlight,
381
+ revealCaretOnFocus,
382
+ selectedIndex,
383
+ };
384
+ }
385
+
386
+ export type EsAutocompleteCombobox = ReturnType<typeof useEsAutocompleteCombobox>;
@@ -0,0 +1,246 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { App } from 'vue';
4
+ import { createApp, nextTick, ref } from 'vue';
5
+ import type { EsAutocompleteSuggestion } from '../types';
6
+ import { useEsAutocompleteSearch } from './autocomplete-search';
7
+
8
+ const DELAY = 300;
9
+
10
+ const apps: App[] = [];
11
+ beforeEach(() => {
12
+ vi.useFakeTimers();
13
+ });
14
+ afterEach(() => {
15
+ apps.forEach((app) => app.unmount());
16
+ apps.length = 0;
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ /** run a composable inside a real component so lifecycle hooks work */
21
+ function withSetup<T>(composable: () => T): { app: App; result: T } {
22
+ let result!: T;
23
+ const app = createApp({
24
+ setup() {
25
+ result = composable();
26
+ return () => null;
27
+ },
28
+ });
29
+ app.mount(document.createElement('div'));
30
+ apps.push(app);
31
+ return { app, result };
32
+ }
33
+
34
+ function makeSearch(overrides: { minChars?: number } = {}) {
35
+ const model = ref('');
36
+ const suggestions = ref<EsAutocompleteSuggestion[]>([]);
37
+ const emitComplete = vi.fn();
38
+ const emitSelect = vi.fn();
39
+ const { app, result: search } = withSetup(() =>
40
+ useEsAutocompleteSearch({
41
+ delay: () => DELAY,
42
+ emitComplete,
43
+ emitSelect,
44
+ minChars: () => overrides.minChars ?? 1,
45
+ model,
46
+ noResultsText: () => 'No results found',
47
+ promptText: () => 'Type for suggestions',
48
+ suggestions: () => suggestions.value,
49
+ }),
50
+ );
51
+ /** type a value and let the model watcher run */
52
+ async function type(value: string) {
53
+ model.value = value;
54
+ await nextTick();
55
+ }
56
+ return { app, emitComplete, emitSelect, model, search, suggestions, type };
57
+ }
58
+
59
+ describe('useEsAutocompleteSearch complete debouncing', () => {
60
+ it('emits complete once per typing pause, with the trimmed query', async () => {
61
+ const { emitComplete, type } = makeSearch();
62
+ await type('s');
63
+ vi.advanceTimersByTime(DELAY - 50);
64
+ await type('so');
65
+ await type(' solar ');
66
+ vi.advanceTimersByTime(DELAY);
67
+ expect(emitComplete).toHaveBeenCalledTimes(1);
68
+ expect(emitComplete).toHaveBeenCalledWith('solar');
69
+ });
70
+
71
+ it('does not emit below minChars', async () => {
72
+ const { emitComplete, type } = makeSearch({ minChars: 3 });
73
+ await type('so');
74
+ vi.advanceTimersByTime(DELAY * 2);
75
+ expect(emitComplete).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('does not re-fetch when selection copies the suggestion text into the input', async () => {
79
+ const { emitComplete, search, type } = makeSearch();
80
+ // selection is terminal: Reka sets the model to the chosen text afterward
81
+ search.onSelect({ id: 'a', text: 'solar batteries' });
82
+ await type('solar batteries');
83
+ vi.advanceTimersByTime(DELAY * 2);
84
+ expect(emitComplete).not.toHaveBeenCalled();
85
+
86
+ // ...but the suppression is one-shot: typing again fetches normally
87
+ await type('solar batteries plus');
88
+ vi.advanceTimersByTime(DELAY);
89
+ expect(emitComplete).toHaveBeenCalledWith('solar batteries plus');
90
+ });
91
+
92
+ it('unmount cancels a pending complete', async () => {
93
+ const { app, emitComplete, type } = makeSearch();
94
+ await type('solar');
95
+ app.unmount();
96
+ vi.advanceTimersByTime(DELAY * 2);
97
+ expect(emitComplete).not.toHaveBeenCalled();
98
+ });
99
+ });
100
+
101
+ describe('useEsAutocompleteSearch panel message and suggestion gating', () => {
102
+ it('walks prompt → results → no-results → prompt-while-pending correctly', async () => {
103
+ const { search, suggestions, type } = makeSearch();
104
+ // nothing searched yet
105
+ expect(search.panelMessage.value).toBe('Type for suggestions');
106
+
107
+ // results arrive: no message
108
+ await type('solar');
109
+ suggestions.value = [{ id: 'a', text: 'solar batteries' }];
110
+ await nextTick();
111
+ expect(search.panelMessage.value).toBe('');
112
+
113
+ // a search came back empty: no-results
114
+ await type('solarx');
115
+ suggestions.value = [];
116
+ await nextTick();
117
+ expect(search.panelMessage.value).toBe('No results found');
118
+
119
+ // the query changed again, so a new search is pending: prompt, not the
120
+ // previous query's stale no-results
121
+ await type('solarxy');
122
+ expect(search.panelMessage.value).toBe('Type for suggestions');
123
+ });
124
+
125
+ it('exposes the no-results announcement exactly when a search came back empty', async () => {
126
+ const { search, suggestions, type } = makeSearch();
127
+ // nothing searched yet: nothing to announce
128
+ expect(search.noResultsAnnouncement.value).toBe('');
129
+
130
+ await type('solar');
131
+ suggestions.value = [{ id: 'a', text: 'solar batteries' }];
132
+ await nextTick();
133
+ expect(search.noResultsAnnouncement.value).toBe('');
134
+
135
+ // a search that came back empty announces the no-results message
136
+ await type('solarx');
137
+ suggestions.value = [];
138
+ await nextTick();
139
+ expect(search.noResultsAnnouncement.value).toBe('No results found');
140
+
141
+ // a pending search announces nothing: the prompt is guidance, not a
142
+ // state change
143
+ await type('solarxy');
144
+ expect(search.noResultsAnnouncement.value).toBe('');
145
+ });
146
+
147
+ it('holds back the stale list after a selection until the app answers again', async () => {
148
+ const { search, suggestions, type } = makeSearch();
149
+ await type('12');
150
+ suggestions.value = [
151
+ { id: 'a', text: '12 Maple Ave, Cambridge, MA 02138' },
152
+ { id: 'b', text: '123 Main St, Boston, MA 02108' },
153
+ ];
154
+ await nextTick();
155
+ expect(search.effectiveSuggestions.value).toHaveLength(2);
156
+
157
+ // selection fills the input with the full text; the app's list matched
158
+ // "12", not that text, so a refocused panel shows the prompt instead
159
+ search.onSelect({ id: 'b', text: '123 Main St, Boston, MA 02108' });
160
+ await type('123 Main St, Boston, MA 02108');
161
+ expect(search.effectiveSuggestions.value).toHaveLength(0);
162
+ expect(search.panelMessage.value).toBe('Type for suggestions');
163
+
164
+ // editing fetches again, and the app's answer clears the staleness
165
+ await type('123 Main St B');
166
+ vi.advanceTimersByTime(DELAY);
167
+ suggestions.value = [{ id: 'b', text: '123 Main St, Boston, MA 02108' }];
168
+ await nextTick();
169
+ expect(search.effectiveSuggestions.value).toHaveLength(1);
170
+ expect(search.panelMessage.value).toBe('');
171
+ });
172
+
173
+ it('fills again after a selection even when the app answers with the same array', async () => {
174
+ // an app that memoises per query hands back the identical array, so the
175
+ // suggestions watcher never fires and only the edit can end the staleness
176
+ const CACHED = [{ id: 'a', text: 'apple' }];
177
+ const model = ref('');
178
+ const search = withSetup(() =>
179
+ useEsAutocompleteSearch({
180
+ delay: () => DELAY,
181
+ emitComplete: () => undefined,
182
+ emitSelect: () => undefined,
183
+ minChars: () => 1,
184
+ model,
185
+ noResultsText: () => 'No results found',
186
+ promptText: () => 'Type for suggestions',
187
+ suggestions: () => CACHED,
188
+ }),
189
+ ).result;
190
+
191
+ model.value = 'app';
192
+ await nextTick();
193
+ expect(search.effectiveSuggestions.value).toHaveLength(1);
194
+
195
+ search.onSelect(CACHED[0]!);
196
+ model.value = 'apple';
197
+ await nextTick();
198
+ expect(search.effectiveSuggestions.value).toHaveLength(0);
199
+
200
+ // searching again returns the very same array the app cached
201
+ model.value = 'app';
202
+ await nextTick();
203
+ expect(search.effectiveSuggestions.value).toHaveLength(1);
204
+ });
205
+
206
+ it('shows suggestions an app refreshes in its own select handler', async () => {
207
+ const { search, suggestions, type } = makeSearch();
208
+ await type('12');
209
+ suggestions.value = [{ id: 'a', text: '12 Maple Ave, Cambridge, MA 02138' }];
210
+ await nextTick();
211
+
212
+ // the app's select handler runs inside the select emit, before the
213
+ // model picks up the chosen text
214
+ search.onSelect({ id: 'a', text: '12 Maple Ave, Cambridge, MA 02138' });
215
+ suggestions.value = [{ id: 'b', text: '12 Maple Ave rear unit, Cambridge, MA 02138' }];
216
+ await type('12 Maple Ave, Cambridge, MA 02138');
217
+ expect(search.effectiveSuggestions.value).toHaveLength(1);
218
+ });
219
+
220
+ it('shows the prompt, not no-results, when the app clears its list on select', async () => {
221
+ const { search, suggestions, type } = makeSearch();
222
+ await type('12');
223
+ suggestions.value = [{ id: 'a', text: '12 Maple Ave, Cambridge, MA 02138' }];
224
+ await nextTick();
225
+
226
+ search.onSelect({ id: 'a', text: '12 Maple Ave, Cambridge, MA 02138' });
227
+ suggestions.value = [];
228
+ await type('12 Maple Ave, Cambridge, MA 02138');
229
+ expect(search.effectiveSuggestions.value).toHaveLength(0);
230
+ expect(search.panelMessage.value).toBe('Type for suggestions');
231
+ });
232
+
233
+ it('gates suggestions below minChars with a referentially stable empty list', async () => {
234
+ const { search, suggestions, type } = makeSearch({ minChars: 3 });
235
+ suggestions.value = [{ id: 'a', text: 'solar batteries' }];
236
+ await type('so');
237
+ const first = search.effectiveSuggestions.value;
238
+ expect(first).toHaveLength(0);
239
+ await type('s');
240
+ // same reference: downstream watchers must not re-fire per keystroke
241
+ expect(search.effectiveSuggestions.value).toBe(first);
242
+
243
+ await type('sol');
244
+ expect(search.effectiveSuggestions.value).toHaveLength(1);
245
+ });
246
+ });