@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.
- package/CHANGELOG.md +7 -0
- package/app/components/es-autocomplete-clear-button.vue +48 -0
- package/app/components/es-autocomplete-desktop.vue +619 -0
- package/app/components/es-autocomplete-field.vue +110 -0
- package/app/components/es-autocomplete-item.vue +101 -0
- package/app/components/es-autocomplete-label.vue +28 -0
- package/app/components/es-autocomplete-mobile.vue +452 -0
- package/app/components/es-autocomplete-suggestion-text.vue +33 -0
- package/app/components/es-autocomplete.vue +224 -0
- package/app/composables/autocomplete-announcer.test.ts +70 -0
- package/app/composables/autocomplete-announcer.ts +41 -0
- package/app/composables/autocomplete-choreography.ts +258 -0
- package/app/composables/autocomplete-combobox.test.ts +464 -0
- package/app/composables/autocomplete-combobox.ts +386 -0
- package/app/composables/autocomplete-search.test.ts +246 -0
- package/app/composables/autocomplete-search.ts +128 -0
- package/app/composables/autocomplete-visible-rows.test.ts +209 -0
- package/app/composables/autocomplete-visible-rows.ts +116 -0
- package/app/types/es-autocomplete.ts +13 -0
- package/app/types/index.ts +1 -0
- package/app/utils/animation.ts +23 -0
- package/app/utils/autocomplete.test.ts +103 -0
- package/app/utils/autocomplete.ts +104 -0
- package/package.json +4 -2
|
@@ -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
|
+
});
|