@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,464 @@
1
+ // @vitest-environment happy-dom
2
+ import { 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 { useEsAutocompleteCombobox } from './autocomplete-combobox';
7
+
8
+ /** run the composable inside a real component so watchers are scoped */
9
+ function withSetup<T>(composable: () => T): { app: App; result: T } {
10
+ let result!: T;
11
+ const app = createApp({
12
+ setup() {
13
+ result = composable();
14
+ return () => null;
15
+ },
16
+ });
17
+ app.mount(document.createElement('div'));
18
+ return { app, result };
19
+ }
20
+
21
+ function makeCombobox(
22
+ initialSuggestions: EsAutocompleteSuggestion[] = [{ id: 'a', text: 'solar batteries' }],
23
+ { autoSelect = false, selectOnBlur = false } = {},
24
+ ) {
25
+ const inputEl = document.createElement('input');
26
+ document.body.append(inputEl);
27
+ const model = ref('solar');
28
+ const suggestions = ref(initialSuggestions);
29
+ const close = vi.fn();
30
+ const emitSelect = vi.fn();
31
+ const { result: combobox } = withSetup(() =>
32
+ useEsAutocompleteCombobox({
33
+ autoSelect: () => autoSelect,
34
+ selectOnBlur: () => selectOnBlur,
35
+ close,
36
+ emitSelect,
37
+ idPrefix: 'test',
38
+ inputEl: ref(inputEl),
39
+ model,
40
+ suggestions: () => suggestions.value,
41
+ }),
42
+ );
43
+ return { close, combobox, emitSelect, inputEl, model, suggestions };
44
+ }
45
+
46
+ function keydown(key: string, init: { isComposing?: boolean } = {}) {
47
+ const event = new KeyboardEvent('keydown', { cancelable: true, key });
48
+ if (init.isComposing) {
49
+ Object.defineProperty(event, 'isComposing', { value: true });
50
+ }
51
+ return event;
52
+ }
53
+
54
+ const THREE = [
55
+ { id: 'a', text: 'apple' },
56
+ { id: 'b', text: 'apricot' },
57
+ { id: 'c', text: 'avocado' },
58
+ ];
59
+
60
+ describe('useEsAutocompleteCombobox arrow navigation and mirroring', () => {
61
+ it('cycles down through every suggestion, back to the input, and around again', () => {
62
+ const { combobox, model } = makeCombobox(THREE);
63
+ model.value = 'a';
64
+ const down = () => combobox.onKeydown(keydown('ArrowDown'));
65
+ down();
66
+ expect(combobox.displayValue.value).toBe('apple');
67
+ expect(combobox.activeDescendant.value).toBe('test-option-0');
68
+ down();
69
+ down();
70
+ expect(combobox.displayValue.value).toBe('avocado');
71
+ // past the last suggestion: back to the input showing the typed text
72
+ down();
73
+ expect(combobox.displayValue.value).toBe('a');
74
+ expect(combobox.activeDescendant.value).toBeUndefined();
75
+ down();
76
+ expect(combobox.displayValue.value).toBe('apple');
77
+ });
78
+
79
+ it('cycles up from the input to the last suggestion and back', () => {
80
+ const { combobox, model } = makeCombobox(THREE);
81
+ model.value = 'a';
82
+ const up = () => combobox.onKeydown(keydown('ArrowUp'));
83
+ up();
84
+ expect(combobox.displayValue.value).toBe('avocado');
85
+ up();
86
+ up();
87
+ expect(combobox.displayValue.value).toBe('apple');
88
+ up();
89
+ expect(combobox.displayValue.value).toBe('a');
90
+ });
91
+
92
+ it('mirrors only keyboard highlights: hovering never changes the field', () => {
93
+ const { combobox, model } = makeCombobox(THREE);
94
+ model.value = 'a';
95
+ combobox.onOptionPointermove(1);
96
+ expect(combobox.displayValue.value).toBe('a');
97
+ expect(combobox.activeDescendant.value).toBe('test-option-1');
98
+ expect(combobox.keyboardNav.value).toBe(false);
99
+ });
100
+
101
+ it("holds the arrows' highlight while the pointer passes over the list", () => {
102
+ const { combobox, model } = makeCombobox(THREE);
103
+ model.value = 'a';
104
+ combobox.onKeydown(keydown('ArrowDown'));
105
+ expect(combobox.displayValue.value).toBe('apple');
106
+ // a panel opens under a resting cursor, so this is a twitch, not a choice
107
+ combobox.onOptionPointermove(2);
108
+ expect(combobox.selectedIndex.value).toBe(0);
109
+ expect(combobox.activeDescendant.value).toBe('test-option-0');
110
+ expect(combobox.displayValue.value).toBe('apple');
111
+ });
112
+
113
+ it('arrows are a no-op while there is nothing displayed', () => {
114
+ const { combobox } = makeCombobox([]);
115
+ combobox.onKeydown(keydown('ArrowDown'));
116
+ expect(combobox.activeDescendant.value).toBeUndefined();
117
+ });
118
+
119
+ it('prevents the caret-moving default on arrows', () => {
120
+ const { combobox } = makeCombobox(THREE);
121
+ const event = keydown('ArrowDown');
122
+ combobox.onKeydown(event);
123
+ expect(event.defaultPrevented).toBe(true);
124
+ });
125
+
126
+ it('a changed suggestion list drops the highlight and restores the typed text', async () => {
127
+ const { combobox, model, suggestions } = makeCombobox(THREE);
128
+ model.value = 'a';
129
+ combobox.onKeydown(keydown('ArrowDown'));
130
+ expect(combobox.displayValue.value).toBe('apple');
131
+ suggestions.value = [{ id: 'z', text: 'banana' }];
132
+ await nextTick();
133
+ expect(combobox.displayValue.value).toBe('a');
134
+ expect(combobox.activeDescendant.value).toBeUndefined();
135
+ });
136
+
137
+ it('typing ends navigation and takes the edited field value as the model', () => {
138
+ const { combobox, inputEl, model } = makeCombobox(THREE);
139
+ model.value = 'a';
140
+ combobox.onKeydown(keydown('ArrowDown'));
141
+ // the user edits the mirrored suggestion, as on Google
142
+ inputEl.value = 'applex';
143
+ inputEl.dispatchEvent(new Event('input'));
144
+ combobox.onInput({ target: inputEl } as unknown as Event);
145
+ expect(model.value).toBe('applex');
146
+ expect(combobox.displayValue.value).toBe('applex');
147
+ expect(combobox.activeDescendant.value).toBeUndefined();
148
+ });
149
+ });
150
+
151
+ describe('useEsAutocompleteCombobox Enter semantics', () => {
152
+ it('closes and leaves the key to the page when nothing is highlighted', () => {
153
+ const { close, combobox, emitSelect } = makeCombobox();
154
+ const event = keydown('Enter');
155
+ combobox.onKeydown(event);
156
+ expect(emitSelect).not.toHaveBeenCalled();
157
+ expect(close).toHaveBeenCalled();
158
+ // a surrounding <form> submits implicitly, as from any other field
159
+ expect(event.defaultPrevented).toBe(false);
160
+ });
161
+
162
+ it('selects a keyboard-highlighted suggestion instead of submitting a form', () => {
163
+ const { close, combobox, emitSelect, model } = makeCombobox();
164
+ combobox.onKeydown(keydown('ArrowDown'));
165
+ const event = keydown('Enter');
166
+ combobox.onKeydown(event);
167
+ expect(emitSelect).toHaveBeenCalledWith({ id: 'a', text: 'solar batteries' });
168
+ expect(event.defaultPrevented).toBe(true);
169
+ expect(model.value).toBe('solar batteries');
170
+ // a shell that renders the final text mid-close takes it from this argument
171
+ expect(close).toHaveBeenCalledWith('solar batteries');
172
+ });
173
+
174
+ it('selects a pointer-highlighted suggestion', () => {
175
+ const { combobox, emitSelect } = makeCombobox();
176
+ combobox.onOptionPointermove(0);
177
+ combobox.onKeydown(keydown('Enter'));
178
+ expect(emitSelect).toHaveBeenCalled();
179
+ });
180
+
181
+ it('ignores the Enter that commits an IME composition', () => {
182
+ const { close, combobox, emitSelect } = makeCombobox();
183
+ combobox.onKeydown(keydown('Enter', { isComposing: true }));
184
+ expect(emitSelect).not.toHaveBeenCalled();
185
+ expect(close).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it('selects nothing after a list change reset the highlight', async () => {
189
+ const { combobox, emitSelect, suggestions } = makeCombobox(THREE);
190
+ combobox.onKeydown(keydown('ArrowDown'));
191
+ suggestions.value = [{ id: 'z', text: 'banana' }];
192
+ await nextTick();
193
+ const event = keydown('Enter');
194
+ combobox.onKeydown(event);
195
+ expect(emitSelect).not.toHaveBeenCalled();
196
+ expect(event.defaultPrevented).toBe(false);
197
+ });
198
+ });
199
+
200
+ describe('useEsAutocompleteCombobox selection, clearing, and focus retention', () => {
201
+ it('onOptionClick writes the model, closes with the text, and emits the suggestion', () => {
202
+ const { close, combobox, emitSelect, model } = makeCombobox(THREE);
203
+ combobox.onOptionClick(1);
204
+ expect(model.value).toBe('apricot');
205
+ expect(close).toHaveBeenCalledWith('apricot');
206
+ expect(emitSelect).toHaveBeenCalledWith({ id: 'b', text: 'apricot' });
207
+ });
208
+
209
+ it('onClear empties the model and refocuses the input', () => {
210
+ const { combobox, inputEl, model } = makeCombobox();
211
+ combobox.onClear();
212
+ expect(model.value).toBe('');
213
+ expect(document.activeElement).toBe(inputEl);
214
+ });
215
+
216
+ it('list mousedown keeps focus in the input, except on interactive slot content', () => {
217
+ const { combobox } = makeCombobox();
218
+ const row = document.createElement('div');
219
+ const plain = new MouseEvent('mousedown', { cancelable: true });
220
+ Object.defineProperty(plain, 'target', { value: row });
221
+ combobox.onListMousedown(plain);
222
+ expect(plain.defaultPrevented).toBe(true);
223
+
224
+ const button = document.createElement('button');
225
+ row.append(button);
226
+ const interactive = new MouseEvent('mousedown', { cancelable: true });
227
+ Object.defineProperty(interactive, 'target', { value: button });
228
+ combobox.onListMousedown(interactive);
229
+ expect(interactive.defaultPrevented).toBe(false);
230
+ });
231
+ });
232
+
233
+ describe('useEsAutocompleteCombobox arrow-driven blur window', () => {
234
+ it('claims the blur a screen reader makes in answer to an arrow press', () => {
235
+ const { combobox } = makeCombobox(THREE);
236
+ combobox.onKeydown(keydown('ArrowDown'));
237
+ expect(combobox.consumeRewriteBlur()).toBe(true);
238
+ });
239
+
240
+ it('claims it once, so a later blur reads as the user leaving the field', () => {
241
+ const { combobox } = makeCombobox(THREE);
242
+ combobox.onKeydown(keydown('ArrowDown'));
243
+ expect(combobox.consumeRewriteBlur()).toBe(true);
244
+ expect(combobox.consumeRewriteBlur()).toBe(false);
245
+ });
246
+
247
+ it('claims nothing once the window has passed, or without an arrow press', () => {
248
+ const { combobox } = makeCombobox(THREE);
249
+ expect(combobox.consumeRewriteBlur()).toBe(false);
250
+ combobox.onKeydown(keydown('ArrowDown'));
251
+ vi.spyOn(performance, 'now').mockReturnValue(performance.now() + 1_000);
252
+ expect(combobox.consumeRewriteBlur()).toBe(false);
253
+ vi.restoreAllMocks();
254
+ });
255
+ });
256
+
257
+ describe('useEsAutocompleteCombobox automatic selection', () => {
258
+ it('selects the first suggestion as the list arrives', () => {
259
+ const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
260
+ model.value = 'a';
261
+ expect(combobox.selectedIndex.value).toBe(0);
262
+ });
263
+
264
+ it('leaves aria-activedescendant to the user, so a screen reader cursor stays in the field', () => {
265
+ const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
266
+ model.value = 'a';
267
+ expect(combobox.activeDescendant.value).toBeUndefined();
268
+ combobox.onKeydown(keydown('ArrowDown'));
269
+ expect(combobox.activeDescendant.value).toBe('test-option-1');
270
+ });
271
+
272
+ it('selects nothing in the default mode', () => {
273
+ const { combobox, model } = makeCombobox(THREE);
274
+ model.value = 'a';
275
+ expect(combobox.selectedIndex.value).toBe(-1);
276
+ expect(combobox.activeDescendant.value).toBeUndefined();
277
+ });
278
+
279
+ it('completes the typed text inline when the suggestion continues it', () => {
280
+ const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
281
+ model.value = 'ap';
282
+ expect(combobox.displayValue.value).toBe('apple');
283
+ });
284
+
285
+ it('adds only the untyped remainder, leaving the typed characters alone', () => {
286
+ const { combobox, model } = makeCombobox([{ id: 'p', text: 'Pagination' }], { autoSelect: true });
287
+ model.value = 'p';
288
+ expect(combobox.displayValue.value).toBe('pagination');
289
+ model.value = 'pAg';
290
+ expect(combobox.displayValue.value).toBe('pAgination');
291
+ });
292
+
293
+ it("takes the suggestion's own text as the value once it is chosen", () => {
294
+ const { combobox, model } = makeCombobox([{ id: 'p', text: 'Pagination' }], {
295
+ autoSelect: true,
296
+ selectOnBlur: true,
297
+ });
298
+ model.value = 'p';
299
+ expect(combobox.commitAutoSelection()).toBe(true);
300
+ expect(model.value).toBe('Pagination');
301
+ });
302
+
303
+ it('selects without completing when the suggestion matches some other way', () => {
304
+ const { combobox, model } = makeCombobox([{ id: 'a', text: '12 Maple Ave' }], { autoSelect: true });
305
+ model.value = 'maple';
306
+ expect(combobox.selectedIndex.value).toBe(0);
307
+ expect(combobox.displayValue.value).toBe('maple');
308
+ });
309
+
310
+ it('leaves a deletion alone rather than putting the character back', () => {
311
+ const { combobox, inputEl, model } = makeCombobox(THREE, { autoSelect: true });
312
+ model.value = 'ap';
313
+ expect(combobox.displayValue.value).toBe('apple');
314
+ inputEl.value = 'a';
315
+ combobox.onInput({ inputType: 'deleteContentBackward', target: inputEl } as unknown as Event);
316
+ expect(combobox.displayValue.value).toBe('a');
317
+ });
318
+
319
+ it('keeps the completion remainder selected as the typed text grows', async () => {
320
+ const { combobox, inputEl, model } = makeCombobox(THREE, { autoSelect: true });
321
+ inputEl.focus();
322
+ model.value = 'ap';
323
+ // the value the shell renders from displayValue, which the selection follows
324
+ inputEl.value = combobox.displayValue.value;
325
+ await nextTick();
326
+ expect([inputEl.selectionStart, inputEl.selectionEnd]).toEqual([2, 5]);
327
+ // typing over the remainder leaves the same suggestion completing the
328
+ // longer text, so the remainder has to be selected again
329
+ model.value = 'app';
330
+ inputEl.value = combobox.displayValue.value;
331
+ await nextTick();
332
+ expect([inputEl.selectionStart, inputEl.selectionEnd]).toEqual([3, 5]);
333
+ });
334
+
335
+ it('claims a rewrite for the completion it writes, never for the typing over it', async () => {
336
+ const { combobox, inputEl, model } = makeCombobox(THREE, { autoSelect: true });
337
+ inputEl.focus();
338
+ model.value = 'ap';
339
+ inputEl.value = combobox.displayValue.value;
340
+ await nextTick();
341
+ expect(combobox.consumeRewriteBlur()).toBe(true);
342
+ model.value = 'app';
343
+ inputEl.value = combobox.displayValue.value;
344
+ await nextTick();
345
+ expect(combobox.consumeRewriteBlur()).toBe(false);
346
+ });
347
+
348
+ it('names the selected suggestion for the shell to announce', () => {
349
+ const { combobox } = makeCombobox(THREE, { autoSelect: true });
350
+ expect(combobox.autoSelectedText.value).toBe('apple');
351
+ const { combobox: off } = makeCombobox(THREE);
352
+ expect(off.autoSelectedText.value).toBe('');
353
+ });
354
+
355
+ it('keeps the completion while the pointer passes over the list', () => {
356
+ const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
357
+ model.value = 'ap';
358
+ expect(combobox.displayValue.value).toBe('apple');
359
+ combobox.onOptionPointermove(2);
360
+ // the pointer highlights what it is over, and the field keeps its proposal
361
+ expect(combobox.selectedIndex.value).toBe(2);
362
+ expect(combobox.displayValue.value).toBe('apple');
363
+ });
364
+
365
+ it('restores the completion, still selected, when the arrows come back to the input', async () => {
366
+ const { combobox, inputEl, model } = makeCombobox(THREE, { autoSelect: true });
367
+ inputEl.focus();
368
+ model.value = 'ap';
369
+ const down = () => combobox.onKeydown(keydown('ArrowDown'));
370
+ down();
371
+ expect(combobox.displayValue.value).toBe('apricot');
372
+ down();
373
+ down();
374
+ expect(combobox.selectedIndex.value).toBe(0);
375
+ expect(combobox.displayValue.value).toBe('apple');
376
+ inputEl.value = combobox.displayValue.value;
377
+ // three ticks: the completion's own flush, then the two the caret-to-end
378
+ // move would have taken had the arrows still claimed the field
379
+ await nextTick();
380
+ await nextTick();
381
+ await nextTick();
382
+ expect([inputEl.selectionStart, inputEl.selectionEnd]).toEqual([2, 5]);
383
+ });
384
+
385
+ it("holds the arrows' highlight while the pointer passes over the list", () => {
386
+ const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
387
+ model.value = 'ap';
388
+ combobox.onKeydown(keydown('ArrowDown'));
389
+ expect(combobox.selectedIndex.value).toBe(1);
390
+ combobox.onOptionPointermove(2);
391
+ expect(combobox.selectedIndex.value).toBe(1);
392
+ expect(combobox.displayValue.value).toBe('apricot');
393
+ });
394
+
395
+ it('Enter chooses the automatically selected suggestion', () => {
396
+ const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true });
397
+ model.value = 'a';
398
+ const event = keydown('Enter');
399
+ combobox.onKeydown(event);
400
+ expect(event.defaultPrevented).toBe(true);
401
+ expect(emitSelect).toHaveBeenCalledWith(THREE[0]);
402
+ });
403
+
404
+ it('arrows carry on from the automatic selection rather than restarting', () => {
405
+ const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
406
+ model.value = 'a';
407
+ combobox.onKeydown(keydown('ArrowDown'));
408
+ expect(combobox.displayValue.value).toBe('apricot');
409
+ });
410
+
411
+ it('commits the selection on the way out', () => {
412
+ const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true, selectOnBlur: true });
413
+ model.value = 'a';
414
+ expect(combobox.commitAutoSelection()).toBe(true);
415
+ expect(emitSelect).toHaveBeenCalledWith(THREE[0]);
416
+ expect(model.value).toBe('apple');
417
+ });
418
+
419
+ it('commits the suggestion the arrows moved to, not the one it started on', () => {
420
+ const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true, selectOnBlur: true });
421
+ model.value = 'a';
422
+ combobox.onKeydown(keydown('ArrowDown'));
423
+ combobox.onKeydown(keydown('ArrowDown'));
424
+ expect(combobox.commitAutoSelection()).toBe(true);
425
+ expect(emitSelect).toHaveBeenCalledWith(THREE[2]);
426
+ expect(model.value).toBe('avocado');
427
+ });
428
+
429
+ it('commits the held suggestion when a pointer merely rests over another', () => {
430
+ const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true, selectOnBlur: true });
431
+ model.value = 'a';
432
+ combobox.onOptionPointermove(2);
433
+ expect(combobox.commitAutoSelection()).toBe(true);
434
+ expect(emitSelect).toHaveBeenCalledWith(THREE[0]);
435
+ });
436
+
437
+ it('commits nothing in the default mode, whatever the arrows reached', () => {
438
+ const { combobox, emitSelect, model } = makeCombobox(THREE);
439
+ model.value = 'a';
440
+ combobox.onKeydown(keydown('ArrowDown'));
441
+ expect(combobox.commitAutoSelection()).toBe(false);
442
+ expect(emitSelect).not.toHaveBeenCalled();
443
+ });
444
+
445
+ it('commits nothing on the way out unless the field is asked to take a value', () => {
446
+ const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true });
447
+ model.value = 'a';
448
+ // the suggestion is still active, and still what Enter would choose
449
+ expect(combobox.selectedIndex.value).toBe(0);
450
+ expect(combobox.displayValue.value).toBe('apple');
451
+ expect(combobox.commitAutoSelection()).toBe(false);
452
+ expect(emitSelect).not.toHaveBeenCalled();
453
+ combobox.onKeydown(keydown('Enter'));
454
+ expect(emitSelect).toHaveBeenCalledWith(THREE[0]);
455
+ });
456
+
457
+ it('commits nothing with no list displayed, or in the default mode', () => {
458
+ const { combobox: empty } = makeCombobox([], { autoSelect: true, selectOnBlur: true });
459
+ expect(empty.commitAutoSelection()).toBe(false);
460
+ const { combobox, emitSelect } = makeCombobox(THREE);
461
+ expect(combobox.commitAutoSelection()).toBe(false);
462
+ expect(emitSelect).not.toHaveBeenCalled();
463
+ });
464
+ });