@energysage/es-ds-components 6.3.2 → 6.3.3

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [6.3.3](https://github.com/EnergySage/es-ds/compare/es-ds-components-v6.3.2...es-ds-components-v6.3.3) (2026-10-19)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * exclude tests from package to prevent downstream type errors ([#1798](https://github.com/EnergySage/es-ds/issues/1798)) ([07723a3](https://github.com/EnergySage/es-ds/commit/07723a36cefd46a8eacbc2c63413a753702aa34c))
9
+
3
10
  ## [6.3.2](https://github.com/EnergySage/es-ds/compare/es-ds-components-v6.3.1...es-ds-components-v6.3.2) (2026-10-14)
4
11
 
5
12
 
package/nuxt.config.ts CHANGED
@@ -109,6 +109,7 @@ export default defineNuxtConfig({
109
109
  },
110
110
  },
111
111
  },
112
+ // Applies to all consuming apps too, since layer config merges into theirs
112
113
  typescript: {
113
114
  typeCheck: true,
114
115
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energysage/es-ds-components",
3
- "version": "6.3.2",
3
+ "version": "6.3.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "An EnergySage Vue component library",
@@ -18,6 +18,13 @@
18
18
  "url": "https://github.com/EnergySage/es-ds/issues"
19
19
  },
20
20
  "main": "./nuxt.config.ts",
21
+ "files": [
22
+ "app",
23
+ "nuxt.config.ts",
24
+ "CHANGELOG.md",
25
+ "README.md",
26
+ "!**/*.test.ts"
27
+ ],
21
28
  "scripts": {
22
29
  "test": "vitest run",
23
30
  "lint:eslint": "eslint .",
package/.prettierignore DELETED
@@ -1,2 +0,0 @@
1
- # Managed by release-please
2
- CHANGELOG.md
package/.prettierrc.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "printWidth": 119,
3
- "useTabs": false,
4
- "singleQuote": true,
5
- "trailingComma": "all",
6
- "bracketSpacing": true,
7
- "bracketSameLine": true,
8
- "singleAttributePerLine": true,
9
- "tabWidth": 4
10
- }
@@ -1,70 +0,0 @@
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 { useEsAutocompleteAnnouncer } from './autocomplete-announcer';
6
-
7
- /** run the composable inside a real component so watchers are scoped */
8
- function withSetup<T>(composable: () => T): { app: App; result: T } {
9
- let result!: T;
10
- const app = createApp({
11
- setup() {
12
- result = composable();
13
- return () => null;
14
- },
15
- });
16
- app.mount(document.createElement('div'));
17
- return { app, result };
18
- }
19
-
20
- function makeAnnouncer(initial = '') {
21
- const source = ref(initial);
22
- const { app, result: announced } = withSetup(() => useEsAutocompleteAnnouncer(() => source.value));
23
- return { announced, app, source };
24
- }
25
-
26
- describe('useEsAutocompleteAnnouncer', () => {
27
- beforeEach(() => vi.useFakeTimers());
28
- afterEach(() => vi.useRealTimers());
29
-
30
- it('holds the text back until the listbox has stopped changing', async () => {
31
- const { announced, source } = makeAnnouncer();
32
- source.value = '34 Prospect St';
33
- await nextTick();
34
- expect(announced.value).toBe('');
35
- vi.advanceTimersByTime(250);
36
- expect(announced.value).toBe('34 Prospect St');
37
- });
38
-
39
- it('speaks only the last of several texts arriving together', async () => {
40
- const { announced, source } = makeAnnouncer();
41
- source.value = '34 Prospect St';
42
- await nextTick();
43
- vi.advanceTimersByTime(100);
44
- source.value = '355 Cedar Ct';
45
- await nextTick();
46
- vi.advanceTimersByTime(100);
47
- expect(announced.value).toBe('');
48
- vi.advanceTimersByTime(150);
49
- expect(announced.value).toBe('355 Cedar Ct');
50
- });
51
-
52
- it('empties at once, having nothing to wait behind', async () => {
53
- const { announced, source } = makeAnnouncer();
54
- source.value = '34 Prospect St';
55
- await nextTick();
56
- vi.advanceTimersByTime(250);
57
- source.value = '';
58
- await nextTick();
59
- expect(announced.value).toBe('');
60
- });
61
-
62
- it('drops a text still waiting when the component goes away', async () => {
63
- const { announced, app, source } = makeAnnouncer();
64
- source.value = '34 Prospect St';
65
- await nextTick();
66
- app.unmount();
67
- vi.advanceTimersByTime(250);
68
- expect(announced.value).toBe('');
69
- });
70
- });
@@ -1,523 +0,0 @@
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 suggestionsPending = ref(false);
30
- const close = vi.fn();
31
- const emitSelect = vi.fn();
32
- const { result: combobox } = withSetup(() =>
33
- useEsAutocompleteCombobox({
34
- autoSelect: () => autoSelect,
35
- selectOnBlur: () => selectOnBlur,
36
- close,
37
- emitSelect,
38
- idPrefix: 'test',
39
- inputEl: ref(inputEl),
40
- model,
41
- suggestions: () => suggestions.value,
42
- suggestionsPending: () => suggestionsPending.value,
43
- }),
44
- );
45
- return { close, combobox, emitSelect, inputEl, model, suggestions, suggestionsPending };
46
- }
47
-
48
- function keydown(key: string, init: { isComposing?: boolean } = {}) {
49
- const event = new KeyboardEvent('keydown', { cancelable: true, key });
50
- if (init.isComposing) {
51
- Object.defineProperty(event, 'isComposing', { value: true });
52
- }
53
- return event;
54
- }
55
-
56
- const THREE = [
57
- { id: 'a', text: 'apple' },
58
- { id: 'b', text: 'apricot' },
59
- { id: 'c', text: 'avocado' },
60
- ];
61
-
62
- describe('useEsAutocompleteCombobox arrow navigation and mirroring', () => {
63
- it('cycles down through every suggestion, back to the input, and around again', () => {
64
- const { combobox, model } = makeCombobox(THREE);
65
- model.value = 'a';
66
- const down = () => combobox.onKeydown(keydown('ArrowDown'));
67
- down();
68
- expect(combobox.displayValue.value).toBe('apple');
69
- expect(combobox.activeDescendant.value).toBe('test-option-0');
70
- down();
71
- down();
72
- expect(combobox.displayValue.value).toBe('avocado');
73
- // past the last suggestion: back to the input showing the typed text
74
- down();
75
- expect(combobox.displayValue.value).toBe('a');
76
- expect(combobox.activeDescendant.value).toBeUndefined();
77
- down();
78
- expect(combobox.displayValue.value).toBe('apple');
79
- });
80
-
81
- it('cycles up from the input to the last suggestion and back', () => {
82
- const { combobox, model } = makeCombobox(THREE);
83
- model.value = 'a';
84
- const up = () => combobox.onKeydown(keydown('ArrowUp'));
85
- up();
86
- expect(combobox.displayValue.value).toBe('avocado');
87
- up();
88
- up();
89
- expect(combobox.displayValue.value).toBe('apple');
90
- up();
91
- expect(combobox.displayValue.value).toBe('a');
92
- });
93
-
94
- it('mirrors only keyboard highlights: hovering never changes the field', () => {
95
- const { combobox, model } = makeCombobox(THREE);
96
- model.value = 'a';
97
- combobox.onOptionPointermove(1);
98
- expect(combobox.displayValue.value).toBe('a');
99
- expect(combobox.activeDescendant.value).toBe('test-option-1');
100
- expect(combobox.keyboardNav.value).toBe(false);
101
- });
102
-
103
- it("holds the arrows' highlight while the pointer passes over the list", () => {
104
- const { combobox, model } = makeCombobox(THREE);
105
- model.value = 'a';
106
- combobox.onKeydown(keydown('ArrowDown'));
107
- expect(combobox.displayValue.value).toBe('apple');
108
- // a panel opens under a resting cursor, so this is a twitch, not a choice
109
- combobox.onOptionPointermove(2);
110
- expect(combobox.selectedIndex.value).toBe(0);
111
- expect(combobox.activeDescendant.value).toBe('test-option-0');
112
- expect(combobox.displayValue.value).toBe('apple');
113
- });
114
-
115
- it('arrows are a no-op while there is nothing displayed', () => {
116
- const { combobox } = makeCombobox([]);
117
- combobox.onKeydown(keydown('ArrowDown'));
118
- expect(combobox.activeDescendant.value).toBeUndefined();
119
- });
120
-
121
- it('prevents the caret-moving default on arrows', () => {
122
- const { combobox } = makeCombobox(THREE);
123
- const event = keydown('ArrowDown');
124
- combobox.onKeydown(event);
125
- expect(event.defaultPrevented).toBe(true);
126
- });
127
-
128
- it('a changed suggestion list drops the highlight and restores the typed text', async () => {
129
- const { combobox, model, suggestions } = makeCombobox(THREE);
130
- model.value = 'a';
131
- combobox.onKeydown(keydown('ArrowDown'));
132
- expect(combobox.displayValue.value).toBe('apple');
133
- suggestions.value = [{ id: 'z', text: 'banana' }];
134
- await nextTick();
135
- expect(combobox.displayValue.value).toBe('a');
136
- expect(combobox.activeDescendant.value).toBeUndefined();
137
- });
138
-
139
- it('typing ends navigation and takes the edited field value as the model', () => {
140
- const { combobox, inputEl, model } = makeCombobox(THREE);
141
- model.value = 'a';
142
- combobox.onKeydown(keydown('ArrowDown'));
143
- // the user edits the mirrored suggestion, as on Google
144
- inputEl.value = 'applex';
145
- inputEl.dispatchEvent(new Event('input'));
146
- combobox.onInput({ target: inputEl } as unknown as Event);
147
- expect(model.value).toBe('applex');
148
- expect(combobox.displayValue.value).toBe('applex');
149
- expect(combobox.activeDescendant.value).toBeUndefined();
150
- });
151
- });
152
-
153
- describe('useEsAutocompleteCombobox Enter semantics', () => {
154
- it('closes and leaves the key to the page when nothing is highlighted', () => {
155
- const { close, combobox, emitSelect } = makeCombobox();
156
- const event = keydown('Enter');
157
- combobox.onKeydown(event);
158
- expect(emitSelect).not.toHaveBeenCalled();
159
- expect(close).toHaveBeenCalled();
160
- // a surrounding <form> submits implicitly, as from any other field
161
- expect(event.defaultPrevented).toBe(false);
162
- });
163
-
164
- it('selects a keyboard-highlighted suggestion instead of submitting a form', () => {
165
- const { close, combobox, emitSelect, model } = makeCombobox();
166
- combobox.onKeydown(keydown('ArrowDown'));
167
- const event = keydown('Enter');
168
- combobox.onKeydown(event);
169
- expect(emitSelect).toHaveBeenCalledWith({ id: 'a', text: 'solar batteries' });
170
- expect(event.defaultPrevented).toBe(true);
171
- expect(model.value).toBe('solar batteries');
172
- // a shell that renders the final text mid-close takes it from this argument
173
- expect(close).toHaveBeenCalledWith('solar batteries');
174
- });
175
-
176
- it('selects a pointer-highlighted suggestion', () => {
177
- const { combobox, emitSelect } = makeCombobox();
178
- combobox.onOptionPointermove(0);
179
- combobox.onKeydown(keydown('Enter'));
180
- expect(emitSelect).toHaveBeenCalled();
181
- });
182
-
183
- it('ignores the Enter that commits an IME composition', () => {
184
- const { close, combobox, emitSelect } = makeCombobox();
185
- combobox.onKeydown(keydown('Enter', { isComposing: true }));
186
- expect(emitSelect).not.toHaveBeenCalled();
187
- expect(close).not.toHaveBeenCalled();
188
- });
189
-
190
- it('selects nothing after a list change reset the highlight', async () => {
191
- const { combobox, emitSelect, suggestions } = makeCombobox(THREE);
192
- combobox.onKeydown(keydown('ArrowDown'));
193
- suggestions.value = [{ id: 'z', text: 'banana' }];
194
- await nextTick();
195
- const event = keydown('Enter');
196
- combobox.onKeydown(event);
197
- expect(emitSelect).not.toHaveBeenCalled();
198
- expect(event.defaultPrevented).toBe(false);
199
- });
200
- });
201
-
202
- describe('useEsAutocompleteCombobox selection, clearing, and focus retention', () => {
203
- it('onOptionClick writes the model, closes with the text, and emits the suggestion', () => {
204
- const { close, combobox, emitSelect, model } = makeCombobox(THREE);
205
- combobox.onOptionClick(1);
206
- expect(model.value).toBe('apricot');
207
- expect(close).toHaveBeenCalledWith('apricot');
208
- expect(emitSelect).toHaveBeenCalledWith({ id: 'b', text: 'apricot' });
209
- });
210
-
211
- it('onClear empties the model and refocuses the input', () => {
212
- const { combobox, inputEl, model } = makeCombobox();
213
- combobox.onClear();
214
- expect(model.value).toBe('');
215
- expect(document.activeElement).toBe(inputEl);
216
- });
217
-
218
- it('list mousedown keeps focus in the input, except on interactive slot content', () => {
219
- const { combobox } = makeCombobox();
220
- const row = document.createElement('div');
221
- const plain = new MouseEvent('mousedown', { cancelable: true });
222
- Object.defineProperty(plain, 'target', { value: row });
223
- combobox.onListMousedown(plain);
224
- expect(plain.defaultPrevented).toBe(true);
225
-
226
- const button = document.createElement('button');
227
- row.append(button);
228
- const interactive = new MouseEvent('mousedown', { cancelable: true });
229
- Object.defineProperty(interactive, 'target', { value: button });
230
- combobox.onListMousedown(interactive);
231
- expect(interactive.defaultPrevented).toBe(false);
232
- });
233
- });
234
-
235
- describe('useEsAutocompleteCombobox arrow-driven blur window', () => {
236
- it('claims the blur a screen reader makes in answer to an arrow press', () => {
237
- const { combobox } = makeCombobox(THREE);
238
- combobox.onKeydown(keydown('ArrowDown'));
239
- expect(combobox.consumeRewriteBlur()).toBe(true);
240
- });
241
-
242
- it('claims it once, so a later blur reads as the user leaving the field', () => {
243
- const { combobox } = makeCombobox(THREE);
244
- combobox.onKeydown(keydown('ArrowDown'));
245
- expect(combobox.consumeRewriteBlur()).toBe(true);
246
- expect(combobox.consumeRewriteBlur()).toBe(false);
247
- });
248
-
249
- it('claims nothing once the window has passed, or without an arrow press', () => {
250
- const { combobox } = makeCombobox(THREE);
251
- expect(combobox.consumeRewriteBlur()).toBe(false);
252
- combobox.onKeydown(keydown('ArrowDown'));
253
- vi.spyOn(performance, 'now').mockReturnValue(performance.now() + 1_000);
254
- expect(combobox.consumeRewriteBlur()).toBe(false);
255
- vi.restoreAllMocks();
256
- });
257
- });
258
-
259
- describe('useEsAutocompleteCombobox automatic selection', () => {
260
- it('selects the first suggestion as the list arrives', () => {
261
- const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
262
- model.value = 'a';
263
- expect(combobox.selectedIndex.value).toBe(0);
264
- });
265
-
266
- it('leaves aria-activedescendant to the user, so a screen reader cursor stays in the field', () => {
267
- const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
268
- model.value = 'a';
269
- expect(combobox.activeDescendant.value).toBeUndefined();
270
- combobox.onKeydown(keydown('ArrowDown'));
271
- expect(combobox.activeDescendant.value).toBe('test-option-1');
272
- });
273
-
274
- it('selects nothing in the default mode', () => {
275
- const { combobox, model } = makeCombobox(THREE);
276
- model.value = 'a';
277
- expect(combobox.selectedIndex.value).toBe(-1);
278
- expect(combobox.activeDescendant.value).toBeUndefined();
279
- });
280
-
281
- it('completes the typed text inline when the suggestion continues it', () => {
282
- const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
283
- model.value = 'ap';
284
- expect(combobox.displayValue.value).toBe('apple');
285
- });
286
-
287
- it('adds only the untyped remainder, leaving the typed characters alone', () => {
288
- const { combobox, model } = makeCombobox([{ id: 'p', text: 'Pagination' }], { autoSelect: true });
289
- model.value = 'p';
290
- expect(combobox.displayValue.value).toBe('pagination');
291
- model.value = 'pAg';
292
- expect(combobox.displayValue.value).toBe('pAgination');
293
- });
294
-
295
- it("takes the suggestion's own text as the value once it is chosen", () => {
296
- const { combobox, model } = makeCombobox([{ id: 'p', text: 'Pagination' }], {
297
- autoSelect: true,
298
- selectOnBlur: true,
299
- });
300
- model.value = 'p';
301
- expect(combobox.commitAutoSelection()).toBe(true);
302
- expect(model.value).toBe('Pagination');
303
- });
304
-
305
- it('selects without completing when the suggestion matches some other way', () => {
306
- const { combobox, model } = makeCombobox([{ id: 'a', text: '12 Maple Ave' }], { autoSelect: true });
307
- model.value = 'maple';
308
- expect(combobox.selectedIndex.value).toBe(0);
309
- expect(combobox.displayValue.value).toBe('maple');
310
- });
311
-
312
- it('leaves a deletion alone rather than putting the character back', () => {
313
- const { combobox, inputEl, model } = makeCombobox(THREE, { autoSelect: true });
314
- model.value = 'ap';
315
- expect(combobox.displayValue.value).toBe('apple');
316
- inputEl.value = 'a';
317
- combobox.onInput({ inputType: 'deleteContentBackward', target: inputEl } as unknown as Event);
318
- expect(combobox.displayValue.value).toBe('a');
319
- });
320
-
321
- it('keeps the completion remainder selected as the typed text grows', async () => {
322
- const { combobox, inputEl, model } = makeCombobox(THREE, { autoSelect: true });
323
- inputEl.focus();
324
- model.value = 'ap';
325
- // the value the shell renders from displayValue, which the selection follows
326
- inputEl.value = combobox.displayValue.value;
327
- await nextTick();
328
- expect([inputEl.selectionStart, inputEl.selectionEnd]).toEqual([2, 5]);
329
- // typing over the remainder leaves the same suggestion completing the
330
- // longer text, so the remainder has to be selected again
331
- model.value = 'app';
332
- inputEl.value = combobox.displayValue.value;
333
- await nextTick();
334
- expect([inputEl.selectionStart, inputEl.selectionEnd]).toEqual([3, 5]);
335
- });
336
-
337
- it('claims a rewrite for the completion it writes, never for the typing over it', async () => {
338
- const { combobox, inputEl, model } = makeCombobox(THREE, { autoSelect: true });
339
- inputEl.focus();
340
- model.value = 'ap';
341
- inputEl.value = combobox.displayValue.value;
342
- await nextTick();
343
- expect(combobox.consumeRewriteBlur()).toBe(true);
344
- model.value = 'app';
345
- inputEl.value = combobox.displayValue.value;
346
- await nextTick();
347
- expect(combobox.consumeRewriteBlur()).toBe(false);
348
- });
349
-
350
- it('names the selected suggestion for the shell to announce', () => {
351
- const { combobox } = makeCombobox(THREE, { autoSelect: true });
352
- expect(combobox.autoSelectedText.value).toBe('apple');
353
- const { combobox: off } = makeCombobox(THREE);
354
- expect(off.autoSelectedText.value).toBe('');
355
- });
356
-
357
- it('keeps the completion while the pointer passes over the list', () => {
358
- const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
359
- model.value = 'ap';
360
- expect(combobox.displayValue.value).toBe('apple');
361
- combobox.onOptionPointermove(2);
362
- // the pointer highlights what it is over, and the field keeps its proposal
363
- expect(combobox.selectedIndex.value).toBe(2);
364
- expect(combobox.displayValue.value).toBe('apple');
365
- });
366
-
367
- it('restores the completion, still selected, when the arrows come back to the input', async () => {
368
- const { combobox, inputEl, model } = makeCombobox(THREE, { autoSelect: true });
369
- inputEl.focus();
370
- model.value = 'ap';
371
- const down = () => combobox.onKeydown(keydown('ArrowDown'));
372
- down();
373
- expect(combobox.displayValue.value).toBe('apricot');
374
- down();
375
- down();
376
- expect(combobox.selectedIndex.value).toBe(0);
377
- expect(combobox.displayValue.value).toBe('apple');
378
- inputEl.value = combobox.displayValue.value;
379
- // three ticks: the completion's own flush, then the two the caret-to-end
380
- // move would have taken had the arrows still claimed the field
381
- await nextTick();
382
- await nextTick();
383
- await nextTick();
384
- expect([inputEl.selectionStart, inputEl.selectionEnd]).toEqual([2, 5]);
385
- });
386
-
387
- it("holds the arrows' highlight while the pointer passes over the list", () => {
388
- const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
389
- model.value = 'ap';
390
- combobox.onKeydown(keydown('ArrowDown'));
391
- expect(combobox.selectedIndex.value).toBe(1);
392
- combobox.onOptionPointermove(2);
393
- expect(combobox.selectedIndex.value).toBe(1);
394
- expect(combobox.displayValue.value).toBe('apricot');
395
- });
396
-
397
- it('Enter chooses the automatically selected suggestion', () => {
398
- const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true });
399
- model.value = 'a';
400
- const event = keydown('Enter');
401
- combobox.onKeydown(event);
402
- expect(event.defaultPrevented).toBe(true);
403
- expect(emitSelect).toHaveBeenCalledWith(THREE[0]);
404
- });
405
-
406
- it('arrows carry on from the automatic selection rather than restarting', () => {
407
- const { combobox, model } = makeCombobox(THREE, { autoSelect: true });
408
- model.value = 'a';
409
- combobox.onKeydown(keydown('ArrowDown'));
410
- expect(combobox.displayValue.value).toBe('apricot');
411
- });
412
-
413
- it('commits the selection on the way out', () => {
414
- const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true, selectOnBlur: true });
415
- model.value = 'a';
416
- expect(combobox.commitAutoSelection()).toBe(true);
417
- expect(emitSelect).toHaveBeenCalledWith(THREE[0]);
418
- expect(model.value).toBe('apple');
419
- });
420
-
421
- it('commits the suggestion the arrows moved to, not the one it started on', () => {
422
- const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true, selectOnBlur: true });
423
- model.value = 'a';
424
- combobox.onKeydown(keydown('ArrowDown'));
425
- combobox.onKeydown(keydown('ArrowDown'));
426
- expect(combobox.commitAutoSelection()).toBe(true);
427
- expect(emitSelect).toHaveBeenCalledWith(THREE[2]);
428
- expect(model.value).toBe('avocado');
429
- });
430
-
431
- it('commits the held suggestion when a pointer merely rests over another', () => {
432
- const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true, selectOnBlur: true });
433
- model.value = 'a';
434
- combobox.onOptionPointermove(2);
435
- expect(combobox.commitAutoSelection()).toBe(true);
436
- expect(emitSelect).toHaveBeenCalledWith(THREE[0]);
437
- });
438
-
439
- it('commits nothing in the default mode, whatever the arrows reached', () => {
440
- const { combobox, emitSelect, model } = makeCombobox(THREE);
441
- model.value = 'a';
442
- combobox.onKeydown(keydown('ArrowDown'));
443
- expect(combobox.commitAutoSelection()).toBe(false);
444
- expect(emitSelect).not.toHaveBeenCalled();
445
- });
446
-
447
- it('commits nothing on the way out unless the field is asked to take a value', () => {
448
- const { combobox, emitSelect, model } = makeCombobox(THREE, { autoSelect: true });
449
- model.value = 'a';
450
- // the suggestion is still active, and still what Enter would choose
451
- expect(combobox.selectedIndex.value).toBe(0);
452
- expect(combobox.displayValue.value).toBe('apple');
453
- expect(combobox.commitAutoSelection()).toBe(false);
454
- expect(emitSelect).not.toHaveBeenCalled();
455
- combobox.onKeydown(keydown('Enter'));
456
- expect(emitSelect).toHaveBeenCalledWith(THREE[0]);
457
- });
458
-
459
- it('commits nothing with no list displayed, or in the default mode', () => {
460
- const { combobox: empty } = makeCombobox([], { autoSelect: true, selectOnBlur: true });
461
- expect(empty.commitAutoSelection()).toBe(false);
462
- const { combobox, emitSelect } = makeCombobox(THREE);
463
- expect(combobox.commitAutoSelection()).toBe(false);
464
- expect(emitSelect).not.toHaveBeenCalled();
465
- });
466
- });
467
-
468
- describe('useEsAutocompleteCombobox automatic selection from a list answering an earlier query', () => {
469
- const BEACON = [
470
- { id: 'c', text: '1 Beacon Street, Cambridge, MA, USA' },
471
- { id: 'b', text: '1 Beacon Street, Boston, MA, USA' },
472
- ];
473
-
474
- it('takes nothing on Enter or on the way out once the typing has moved past the list', () => {
475
- const { close, combobox, emitSelect, model, suggestionsPending } = makeCombobox(BEACON, {
476
- autoSelect: true,
477
- selectOnBlur: true,
478
- });
479
- model.value = '02108';
480
- suggestionsPending.value = true;
481
- expect(combobox.selectedIndex.value).toBe(-1);
482
- expect(combobox.autoSelectedText.value).toBe('');
483
- expect(combobox.commitAutoSelection()).toBe(false);
484
- const event = keydown('Enter');
485
- combobox.onKeydown(event);
486
- expect(event.defaultPrevented).toBe(false);
487
- expect(close).toHaveBeenCalled();
488
- expect(emitSelect).not.toHaveBeenCalled();
489
- expect(model.value).toBe('02108');
490
- });
491
-
492
- it('keeps holding the first suggestion while it still continues the typed text', () => {
493
- const { combobox, emitSelect, model, suggestionsPending } = makeCombobox(BEACON, {
494
- autoSelect: true,
495
- selectOnBlur: true,
496
- });
497
- model.value = '1 Beacon Street, Ca';
498
- suggestionsPending.value = true;
499
- // the field shows exactly what leaving it would take
500
- expect(combobox.displayValue.value).toBe('1 Beacon Street, Cambridge, MA, USA');
501
- expect(combobox.commitAutoSelection()).toBe(true);
502
- expect(emitSelect).toHaveBeenCalledWith(BEACON[0]);
503
- });
504
-
505
- it('selects again once the app answers the typed query', () => {
506
- const { combobox, model, suggestionsPending } = makeCombobox(BEACON, { autoSelect: true });
507
- model.value = '1 Beacon St Bos';
508
- suggestionsPending.value = true;
509
- expect(combobox.selectedIndex.value).toBe(-1);
510
- suggestionsPending.value = false;
511
- expect(combobox.selectedIndex.value).toBe(0);
512
- });
513
-
514
- it('still lets the user choose from the list explicitly', () => {
515
- const { combobox, emitSelect, model, suggestionsPending } = makeCombobox(BEACON, { autoSelect: true });
516
- model.value = '1 Beacon St Bos';
517
- suggestionsPending.value = true;
518
- combobox.onKeydown(keydown('ArrowDown'));
519
- combobox.onKeydown(keydown('ArrowDown'));
520
- combobox.onKeydown(keydown('Enter'));
521
- expect(emitSelect).toHaveBeenCalledWith(BEACON[1]);
522
- });
523
- });
@@ -1,312 +0,0 @@
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('marks the list pending from an edit until the app answers the typed query', async () => {
234
- const { search, suggestions, type } = makeSearch();
235
- await type('1 Beacon St');
236
- expect(search.suggestionsPending.value).toBe(true);
237
- vi.advanceTimersByTime(DELAY);
238
- suggestions.value = [{ id: 'a', text: '1 Beacon Street, Cambridge, MA, USA' }];
239
- await nextTick();
240
- expect(search.suggestionsPending.value).toBe(false);
241
-
242
- // typing past the answered query leaves the list on screen, but pending
243
- await type('1 Beacon St Melrose');
244
- expect(search.effectiveSuggestions.value).toHaveLength(1);
245
- expect(search.suggestionsPending.value).toBe(true);
246
-
247
- // whitespace the trimmed query ignores leaves it answered
248
- await type('1 Beacon St ');
249
- expect(search.suggestionsPending.value).toBe(false);
250
- });
251
-
252
- it('keeps a list pending when it answers a search the typing has already moved past', async () => {
253
- const { emitComplete, search, suggestions, type } = makeSearch();
254
- await type('1 Beacon');
255
- vi.advanceTimersByTime(DELAY);
256
- // the answer to "1 Beacon" lands while "1 Beacon St" is still debouncing
257
- await type('1 Beacon St');
258
- suggestions.value = [{ id: 'a', text: '1 Beacon Street, Cambridge, MA, USA' }];
259
- await nextTick();
260
- expect(search.suggestionsPending.value).toBe(true);
261
-
262
- vi.advanceTimersByTime(DELAY);
263
- expect(emitComplete).toHaveBeenLastCalledWith('1 Beacon St');
264
- suggestions.value = [{ id: 'b', text: '1 Beacon Street, Boston, MA, USA' }];
265
- await nextTick();
266
- expect(search.suggestionsPending.value).toBe(false);
267
- });
268
-
269
- it('credits an app that answers synchronously inside its complete handler', async () => {
270
- const model = ref('');
271
- const suggestions = ref<EsAutocompleteSuggestion[]>([]);
272
- const search = withSetup(() =>
273
- useEsAutocompleteSearch({
274
- delay: () => DELAY,
275
- emitComplete: (query) => {
276
- suggestions.value = [{ id: query, text: `${query} batteries` }];
277
- },
278
- emitSelect: () => undefined,
279
- minChars: () => 1,
280
- model,
281
- noResultsText: () => 'No results found',
282
- promptText: () => 'Type for suggestions',
283
- suggestions: () => suggestions.value,
284
- }),
285
- ).result;
286
- model.value = 'solar';
287
- await nextTick();
288
- vi.advanceTimersByTime(DELAY);
289
- await nextTick();
290
- expect(search.suggestionsPending.value).toBe(false);
291
- });
292
-
293
- it('is never pending below minChars, where no list shows', async () => {
294
- const { search, type } = makeSearch({ minChars: 3 });
295
- await type('so');
296
- expect(search.suggestionsPending.value).toBe(false);
297
- });
298
-
299
- it('gates suggestions below minChars with a referentially stable empty list', async () => {
300
- const { search, suggestions, type } = makeSearch({ minChars: 3 });
301
- suggestions.value = [{ id: 'a', text: 'solar batteries' }];
302
- await type('so');
303
- const first = search.effectiveSuggestions.value;
304
- expect(first).toHaveLength(0);
305
- await type('s');
306
- // same reference: downstream watchers must not re-fire per keystroke
307
- expect(search.effectiveSuggestions.value).toBe(first);
308
-
309
- await type('sol');
310
- expect(search.effectiveSuggestions.value).toHaveLength(1);
311
- });
312
- });
@@ -1,209 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { afterEach, describe, expect, it } from 'vitest';
3
- import type { App } from 'vue';
4
- import { createApp, nextTick, ref } from 'vue';
5
- import type { EsAutocompleteSuggestion } from '../types';
6
- import { useEsAutocompleteVisibleRows } from './autocomplete-visible-rows';
7
-
8
- // NOTE: happy-dom has no real layout, so element geometry (offsetHeight,
9
- // clientHeight) is mocked per element. These tests cover the counting/limit
10
- // LOGIC — whole rows per available height, the min-1 floor, and which height
11
- // constraint wins. Whether real browser layout produces that geometry is
12
- // covered by the planned Playwright specs (plan §8a).
13
-
14
- const apps: App[] = [];
15
- afterEach(() => {
16
- apps.forEach((app) => app.unmount());
17
- apps.length = 0;
18
- document.body.innerHTML = '';
19
- });
20
-
21
- /** run a composable inside a real component so lifecycle hooks work */
22
- function withSetup<T>(composable: () => T): T {
23
- let result!: T;
24
- const app = createApp({
25
- setup() {
26
- result = composable();
27
- return () => null;
28
- },
29
- });
30
- app.mount(document.createElement('div'));
31
- apps.push(app);
32
- return result;
33
- }
34
-
35
- function suggestionList(count: number): EsAutocompleteSuggestion[] {
36
- return Array.from({ length: count }, (_, index) => ({ id: `s${index}`, text: `suggestion ${index}` }));
37
- }
38
-
39
- function makeContainer(
40
- rows: { count: number; height: number },
41
- size: { clientHeight?: number; maxHeight?: number },
42
- extras: { messageOnly?: boolean } = {},
43
- ) {
44
- const container = document.createElement('div');
45
- // happy-dom only resolves computed styles (the max-height limit) for
46
- // elements attached to the document
47
- document.body.append(container);
48
- if (size.maxHeight !== undefined) {
49
- container.style.maxHeight = `${size.maxHeight}px`;
50
- }
51
- Object.defineProperty(container, 'clientHeight', {
52
- configurable: true,
53
- get: () => size.clientHeight ?? 0,
54
- });
55
- if (extras.messageOnly) {
56
- container.append(document.createElement('div'));
57
- return container;
58
- }
59
- // rows share one uniform height, matching the es-autocomplete-item styles
60
- for (let index = 0; index < rows.count; index += 1) {
61
- const el = document.createElement('div');
62
- el.dataset.esAutocompleteItem = '';
63
- Object.defineProperty(el, 'offsetHeight', { configurable: true, value: rows.height });
64
- container.append(el);
65
- }
66
- return container;
67
- }
68
-
69
- describe('useEsAutocompleteVisibleRows', () => {
70
- it('renders the number of whole rows that fit the height limit', async () => {
71
- const contentEl = ref<HTMLElement | null>(makeContainer({ count: 7, height: 30 }, { maxHeight: 100 }));
72
- const suggestions = ref(suggestionList(7));
73
- const fit = withSetup(() => useEsAutocompleteVisibleRows(contentEl, suggestions, 10));
74
- await fit.remeasure();
75
- // floor(100 / 30) = 3 whole rows; the fourth would be partially shown
76
- expect(fit.visibleSuggestions.value).toHaveLength(3);
77
- expect(fit.measured.value).toBe(true);
78
- });
79
-
80
- it('never trims below one row, even when none fully fits', async () => {
81
- const contentEl = ref<HTMLElement | null>(makeContainer({ count: 1, height: 200 }, { maxHeight: 100 }));
82
- const suggestions = ref(suggestionList(5));
83
- const fit = withSetup(() => useEsAutocompleteVisibleRows(contentEl, suggestions, 10));
84
- await fit.remeasure();
85
- expect(fit.visibleSuggestions.value).toHaveLength(1);
86
- });
87
-
88
- it('a message-only panel (prompt/no-results, no rows) is measured and untrimmed', async () => {
89
- const contentEl = ref<HTMLElement | null>(
90
- makeContainer({ count: 0, height: 0 }, { maxHeight: 100 }, { messageOnly: true }),
91
- );
92
- const suggestions = ref<EsAutocompleteSuggestion[]>([]);
93
- const fit = withSetup(() => useEsAutocompleteVisibleRows(contentEl, suggestions, 10));
94
- await fit.remeasure();
95
- expect(fit.measured.value).toBe(true);
96
- });
97
-
98
- it('prefers the resolved max-height over the content-sized clientHeight', async () => {
99
- // clientHeight is content-limited (40) but max-height allows 100: the count
100
- // must come from what COULD fit, or a grown viewport never refills
101
- const contentEl = ref<HTMLElement | null>(
102
- makeContainer({ count: 5, height: 30 }, { clientHeight: 40, maxHeight: 100 }),
103
- );
104
- const suggestions = ref(suggestionList(5));
105
- const fit = withSetup(() => useEsAutocompleteVisibleRows(contentEl, suggestions, 10));
106
- await fit.remeasure();
107
- expect(fit.visibleSuggestions.value).toHaveLength(3);
108
- });
109
-
110
- it('falls back to clientHeight when there is no max-height (the mobile takeover list)', async () => {
111
- const contentEl = ref<HTMLElement | null>(makeContainer({ count: 5, height: 30 }, { clientHeight: 70 }));
112
- const suggestions = ref(suggestionList(5));
113
- const fit = withSetup(() => useEsAutocompleteVisibleRows(contentEl, suggestions, 10));
114
- await fit.remeasure();
115
- expect(fit.visibleSuggestions.value).toHaveLength(2);
116
- });
117
-
118
- it('re-measures when the suggestions change', async () => {
119
- const container = makeContainer({ count: 7, height: 30 }, { maxHeight: 100 });
120
- const contentEl = ref<HTMLElement | null>(container);
121
- const suggestions = ref(suggestionList(7));
122
- const fit = withSetup(() => useEsAutocompleteVisibleRows(contentEl, suggestions, 10));
123
- await fit.remeasure();
124
- expect(fit.visibleSuggestions.value).toHaveLength(3);
125
-
126
- container.style.maxHeight = '70px';
127
- suggestions.value = suggestionList(7);
128
- await nextTick(); // the watcher fires
129
- await nextTick(); // remeasure's internal tick
130
- expect(fit.visibleSuggestions.value).toHaveLength(2);
131
- });
132
-
133
- it('adds and removes whole rows on scroll as the available height changes', async () => {
134
- const container = makeContainer({ count: 2, height: 30 }, { maxHeight: 70 });
135
- const contentEl = ref<HTMLElement | null>(container);
136
- const suggestions = ref(suggestionList(7));
137
- const fit = withSetup(() => useEsAutocompleteVisibleRows(contentEl, suggestions, 10));
138
- await fit.remeasure();
139
- expect(fit.visibleSuggestions.value).toHaveLength(2);
140
-
141
- // scrolling moved the anchor and the popper granted more height: the
142
- // count grows from the row height alone, without extra rows pre-rendered
143
- container.style.maxHeight = '130px';
144
- window.dispatchEvent(new Event('scroll'));
145
- await new Promise((resolve) => requestAnimationFrame(resolve)); // the handler's deferred frame
146
- await nextTick(); // remeasure's internal tick
147
- await nextTick();
148
- expect(fit.visibleSuggestions.value).toHaveLength(4);
149
- });
150
-
151
- it('ignores viewport events while inactive: a closed, still-mounted panel never re-measures on scroll', async () => {
152
- const container = makeContainer({ count: 2, height: 30 }, { maxHeight: 70 });
153
- const contentEl = ref<HTMLElement | null>(container);
154
- const suggestions = ref(suggestionList(7));
155
- const active = ref(true);
156
- const fit = withSetup(() =>
157
- useEsAutocompleteVisibleRows(contentEl, suggestions, 10, { active: () => active.value }),
158
- );
159
- await fit.remeasure();
160
- expect(fit.visibleSuggestions.value).toHaveLength(2);
161
-
162
- active.value = false;
163
- container.style.maxHeight = '130px';
164
- window.dispatchEvent(new Event('scroll'));
165
- await new Promise((resolve) => requestAnimationFrame(resolve));
166
- await nextTick();
167
- await nextTick();
168
- expect(fit.visibleSuggestions.value).toHaveLength(2);
169
-
170
- // reactivating restores the viewport tracking
171
- active.value = true;
172
- window.dispatchEvent(new Event('scroll'));
173
- await new Promise((resolve) => requestAnimationFrame(resolve));
174
- await nextTick();
175
- await nextTick();
176
- expect(fit.visibleSuggestions.value).toHaveLength(4);
177
- });
178
-
179
- it('beforeMeasure runs in the same pass, so a limit it writes drives that count', async () => {
180
- const container = makeContainer({ count: 7, height: 30 }, {});
181
- const contentEl = ref<HTMLElement | null>(container);
182
- const suggestions = ref(suggestionList(7));
183
- // the caller positions the container and writes its max-height (the
184
- // desktop shell's positionPanel) right before each measurement
185
- const fit = withSetup(() =>
186
- useEsAutocompleteVisibleRows(contentEl, suggestions, 10, {
187
- beforeMeasure: () => {
188
- container.style.maxHeight = '70px';
189
- },
190
- }),
191
- );
192
- await fit.remeasure();
193
- expect(fit.visibleSuggestions.value).toHaveLength(2);
194
- });
195
-
196
- it('is unmeasured (list hidden) until a container exists', async () => {
197
- const contentEl = ref<HTMLElement | null>(null);
198
- const suggestions = ref(suggestionList(3));
199
- const fit = withSetup(() => useEsAutocompleteVisibleRows(contentEl, suggestions, 10));
200
- expect(fit.measured.value).toBe(false);
201
-
202
- contentEl.value = makeContainer({ count: 3, height: 30 }, { maxHeight: 100 });
203
- await nextTick(); // the watcher fires
204
- await new Promise((resolve) => requestAnimationFrame(resolve)); // the measure's deferred frame
205
- await nextTick(); // remeasure's internal tick
206
- expect(fit.measured.value).toBe(true);
207
- expect(fit.visibleSuggestions.value).toHaveLength(3);
208
- });
209
- });
@@ -1,103 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import type { EsAutocompleteTextSegment } from '../types';
3
- import { splitEsAutocompleteText, splitEsAutocompleteTextLines } from './autocomplete';
4
-
5
- // matched (typed) portions render regular; predictive portions render bold
6
- const regular = (text: string): EsAutocompleteTextSegment => ({ predictive: false, text });
7
- const bold = (text: string): EsAutocompleteTextSegment => ({ predictive: true, text });
8
-
9
- /** segments must always reconstruct the input exactly — no lost or duplicated characters */
10
- function expectReconstructs(text: string, segments: EsAutocompleteTextSegment[]) {
11
- expect(segments.map((segment) => segment.text).join('')).toBe(text);
12
- }
13
-
14
- describe('splitEsAutocompleteText', () => {
15
- it('bolds the predictive remainder of a prefix match', () => {
16
- expect(splitEsAutocompleteText('solar batteries', 'solar')).toEqual([regular('solar'), bold(' batteries')]);
17
- });
18
-
19
- it('matches case-insensitively', () => {
20
- expect(splitEsAutocompleteText('Solar Batteries', 'SOLAR')).toEqual([regular('Solar'), bold(' Batteries')]);
21
- });
22
-
23
- it('matches tokens in any order, at every word start', () => {
24
- expect(splitEsAutocompleteText('123 Main St, Boston, MA', 'boston main')).toEqual([
25
- bold('123 '),
26
- regular('Main'),
27
- bold(' St, '),
28
- regular('Boston'),
29
- bold(', MA'),
30
- ]);
31
- });
32
-
33
- it('prefers word starts: "st" matches "St" but not the middle of "Boston"', () => {
34
- expect(splitEsAutocompleteText('Boston St', 'st')).toEqual([bold('Boston '), regular('St')]);
35
- });
36
-
37
- it('falls back to mid-word matching when a token never matches at a word start', () => {
38
- expect(splitEsAutocompleteText('123 Main St', '3')).toEqual([bold('12'), regular('3'), bold(' Main St')]);
39
- });
40
-
41
- it('renders everything regular when nothing matches, and for a blank query', () => {
42
- expect(splitEsAutocompleteText('solar panels', 'xyz')).toEqual([regular('solar panels')]);
43
- expect(splitEsAutocompleteText('solar panels', ' ')).toEqual([regular('solar panels')]);
44
- });
45
-
46
- it('merges overlapping token matches', () => {
47
- expect(splitEsAutocompleteText('solar panels', 'solar sol')).toEqual([regular('solar'), bold(' panels')]);
48
- });
49
-
50
- it('never mis-slices when lowercasing changes string length (e.g. İ)', () => {
51
- const text = 'İzmir solar';
52
- const segments = splitEsAutocompleteText(text, 'solar');
53
- expectReconstructs(text, segments);
54
- expect(segments).toContainEqual(regular('solar'));
55
- });
56
-
57
- it('always reconstructs the input text exactly', () => {
58
- const cases: Array<[string, string]> = [
59
- ['solar batteries', 'solar'],
60
- ['123 Main St', 'main 123 st'],
61
- ['heat pump water heater', 'heat'],
62
- ['no match here', 'zzz'],
63
- ['', 'anything'],
64
- ];
65
- for (const [text, query] of cases) {
66
- expectReconstructs(text, splitEsAutocompleteText(text, query));
67
- }
68
- });
69
- });
70
-
71
- describe('splitEsAutocompleteTextLines', () => {
72
- const addressLines = ['1200 Beacon St', 'Brookline, MA 02446'];
73
-
74
- it('highlights tokens on the lines they match', () => {
75
- expect(splitEsAutocompleteTextLines(['123 Main St', 'Boston, MA 02108'], 'boston main')).toEqual([
76
- [bold('123 '), regular('Main'), bold(' St')],
77
- [regular('Boston'), bold(', MA 02108')],
78
- ]);
79
- });
80
-
81
- it('bolds a whole line without its own match when another line matched (bolding mode)', () => {
82
- expect(splitEsAutocompleteTextLines(addressLines, 'beacon')).toEqual([
83
- [bold('1200 '), regular('Beacon'), bold(' St')],
84
- [bold('Brookline, MA 02446')],
85
- ]);
86
- });
87
-
88
- it('renders every line regular when no line matches', () => {
89
- expect(splitEsAutocompleteTextLines(addressLines, 'zzz')).toEqual([
90
- [regular('1200 Beacon St')],
91
- [regular('Brookline, MA 02446')],
92
- ]);
93
- });
94
-
95
- it('decides the word-start fallback across all lines together', () => {
96
- // "st" has a word-start match on line 1, so it must NOT fall back to
97
- // mid-word matching inside "Brookline" on line 2
98
- expect(splitEsAutocompleteTextLines(addressLines, 'beacon st')).toEqual([
99
- [bold('1200 '), regular('Beacon'), bold(' '), regular('St')],
100
- [bold('Brookline, MA 02446')],
101
- ]);
102
- });
103
- });
package/eslint.config.mjs DELETED
@@ -1,37 +0,0 @@
1
- import withNuxt from '../es-ds-docs/.nuxt/eslint.config.mjs';
2
-
3
- export default withNuxt()
4
- .override('nuxt/vue/rules', {
5
- rules: {
6
- 'vue/component-name-in-template-casing': [
7
- 'error',
8
- 'kebab-case',
9
- {
10
- registeredComponentsOnly: false,
11
- ignores: [],
12
- },
13
- ],
14
- 'vue/block-order': [
15
- 'error',
16
- {
17
- order: [['script[setup]', 'script:not([setup])'], 'template', 'style'],
18
- },
19
- ],
20
- 'vue/block-lang': [
21
- 'error',
22
- {
23
- script: {
24
- lang: 'ts',
25
- },
26
- },
27
- ],
28
- 'vue/html-self-closing': 'off',
29
- },
30
- })
31
- .override('nuxt/typescript/rules', {
32
- rules: {
33
- '@typescript-eslint/no-explicit-any': 'off',
34
- '@typescript-eslint/no-unsafe-function-type': 'off',
35
- '@typescript-eslint/ban-ts-comment': 'off',
36
- },
37
- });
package/tsconfig.json DELETED
@@ -1,4 +0,0 @@
1
- {
2
- // https://nuxt.com/docs/guide/concepts/typescript
3
- "extends": "../es-ds-docs/.nuxt/tsconfig.json"
4
- }