@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 +7 -0
- package/nuxt.config.ts +1 -0
- package/package.json +8 -1
- package/.prettierignore +0 -2
- package/.prettierrc.json +0 -10
- package/app/composables/autocomplete-announcer.test.ts +0 -70
- package/app/composables/autocomplete-combobox.test.ts +0 -523
- package/app/composables/autocomplete-search.test.ts +0 -312
- package/app/composables/autocomplete-visible-rows.test.ts +0 -209
- package/app/utils/autocomplete.test.ts +0 -103
- package/eslint.config.mjs +0 -37
- package/tsconfig.json +0 -4
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@energysage/es-ds-components",
|
|
3
|
-
"version": "6.3.
|
|
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
package/.prettierrc.json
DELETED
|
@@ -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