@energysage/es-ds-components 6.3.1 → 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 +14 -0
- package/app/components/es-autocomplete-desktop.vue +2 -0
- package/app/components/es-autocomplete-mobile.vue +2 -0
- package/app/components/es-autocomplete.vue +13 -10
- package/app/composables/autocomplete-combobox.ts +30 -11
- package/app/composables/autocomplete-search.ts +18 -3
- 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 -464
- package/app/composables/autocomplete-search.test.ts +0 -246
- 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
|
@@ -1,246 +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('gates suggestions below minChars with a referentially stable empty list', async () => {
|
|
234
|
-
const { search, suggestions, type } = makeSearch({ minChars: 3 });
|
|
235
|
-
suggestions.value = [{ id: 'a', text: 'solar batteries' }];
|
|
236
|
-
await type('so');
|
|
237
|
-
const first = search.effectiveSuggestions.value;
|
|
238
|
-
expect(first).toHaveLength(0);
|
|
239
|
-
await type('s');
|
|
240
|
-
// same reference: downstream watchers must not re-fire per keystroke
|
|
241
|
-
expect(search.effectiveSuggestions.value).toBe(first);
|
|
242
|
-
|
|
243
|
-
await type('sol');
|
|
244
|
-
expect(search.effectiveSuggestions.value).toHaveLength(1);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
@@ -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