@energysage/es-ds-components 6.1.1 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/app/components/es-autocomplete-clear-button.vue +48 -0
- package/app/components/es-autocomplete-desktop.vue +619 -0
- package/app/components/es-autocomplete-field.vue +110 -0
- package/app/components/es-autocomplete-item.vue +101 -0
- package/app/components/es-autocomplete-label.vue +28 -0
- package/app/components/es-autocomplete-mobile.vue +452 -0
- package/app/components/es-autocomplete-suggestion-text.vue +33 -0
- package/app/components/es-autocomplete.vue +224 -0
- package/app/components/es-collapse.vue +6 -1
- package/app/components/es-data-table-simple.vue +8 -1
- package/app/composables/autocomplete-announcer.test.ts +70 -0
- package/app/composables/autocomplete-announcer.ts +41 -0
- package/app/composables/autocomplete-choreography.ts +258 -0
- package/app/composables/autocomplete-combobox.test.ts +464 -0
- package/app/composables/autocomplete-combobox.ts +386 -0
- package/app/composables/autocomplete-search.test.ts +246 -0
- package/app/composables/autocomplete-search.ts +128 -0
- package/app/composables/autocomplete-visible-rows.test.ts +209 -0
- package/app/composables/autocomplete-visible-rows.ts +116 -0
- package/app/types/es-autocomplete.ts +13 -0
- package/app/types/index.ts +1 -0
- package/app/utils/animation.ts +23 -0
- package/app/utils/autocomplete.test.ts +103 -0
- package/app/utils/autocomplete.ts +104 -0
- package/package.json +5 -3
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import { computed, onUnmounted, ref, watch } from 'vue';
|
|
3
|
+
import type { EsAutocompleteSuggestion } from '../types';
|
|
4
|
+
|
|
5
|
+
interface AutocompleteSearchOptions {
|
|
6
|
+
delay: () => number;
|
|
7
|
+
emitComplete: (query: string) => void;
|
|
8
|
+
emitSelect: (suggestion: EsAutocompleteSuggestion) => void;
|
|
9
|
+
minChars: () => number;
|
|
10
|
+
model: Ref<string>;
|
|
11
|
+
noResultsText: () => string;
|
|
12
|
+
promptText: () => string;
|
|
13
|
+
suggestions: () => EsAutocompleteSuggestion[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// the shared constant keeps effectiveSuggestions referentially stable across
|
|
17
|
+
// keystrokes below minChars, so downstream watchers don't re-fire for an
|
|
18
|
+
// unchanged empty list
|
|
19
|
+
const EMPTY_SUGGESTIONS: EsAutocompleteSuggestion[] = [];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* the search state behind es-autocomplete.vue: the debounced 'complete' contract,
|
|
23
|
+
* the minChars gate, the staleness of the app's list after a selection, and the
|
|
24
|
+
* empty-state messaging. here so it is unit-testable without a mount.
|
|
25
|
+
*/
|
|
26
|
+
export function useEsAutocompleteSearch(options: AutocompleteSearchOptions) {
|
|
27
|
+
const { model } = options;
|
|
28
|
+
|
|
29
|
+
// a selection makes the app's list stale: it matched the typed query, not the
|
|
30
|
+
// text just filled in, and no 'complete' fires to refresh it. hold it back —
|
|
31
|
+
// a refocused panel shows promptText — until the app answers the next edit.
|
|
32
|
+
const suggestionsStale = ref(false);
|
|
33
|
+
|
|
34
|
+
// pass an empty list below minChars so no suggestions show for too-short queries
|
|
35
|
+
const effectiveSuggestions = computed(() => {
|
|
36
|
+
if (suggestionsStale.value || model.value.trim().length < options.minChars()) {
|
|
37
|
+
return EMPTY_SUGGESTIONS;
|
|
38
|
+
}
|
|
39
|
+
return options.suggestions();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// one cancellable handle for the debounced 'complete': every terminal path
|
|
43
|
+
// (selection, submit, short query, unmount) cancels through the same place
|
|
44
|
+
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
45
|
+
function cancelPendingComplete() {
|
|
46
|
+
if (searchTimeout) {
|
|
47
|
+
clearTimeout(searchTimeout);
|
|
48
|
+
searchTimeout = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function scheduleComplete(query: string) {
|
|
52
|
+
cancelPendingComplete();
|
|
53
|
+
searchTimeout = setTimeout(() => {
|
|
54
|
+
options.emitComplete(query);
|
|
55
|
+
}, options.delay());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let lastSelectedText: string | null = null;
|
|
59
|
+
|
|
60
|
+
// whether the app's last update was empty, which tells "search found nothing"
|
|
61
|
+
// (noResultsText) from "nothing has answered yet" (promptText)
|
|
62
|
+
const noResults = ref(false);
|
|
63
|
+
watch(
|
|
64
|
+
options.suggestions,
|
|
65
|
+
(list) => {
|
|
66
|
+
noResults.value = list.length === 0;
|
|
67
|
+
suggestionsStale.value = false;
|
|
68
|
+
},
|
|
69
|
+
// depth 1 so apps that mutate the array in place (push/splice) are seen too
|
|
70
|
+
{ deep: 1 },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// what an open panel shows with nothing to render: the no-results message once
|
|
74
|
+
// a search has come back empty, otherwise the prompt
|
|
75
|
+
const queryLongEnough = computed(() => model.value.trim().length >= options.minChars());
|
|
76
|
+
const panelMessage = computed(() => {
|
|
77
|
+
if (effectiveSuggestions.value.length) {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
return noResults.value && queryLongEnough.value ? options.noResultsText() : options.promptText();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// rendered into the shells' live regions when nothing is displayed, so screen
|
|
84
|
+
// readers hear that a search came back empty; each shell announces its own
|
|
85
|
+
// displayed count. the prompt is guidance, so it is never announced.
|
|
86
|
+
const noResultsAnnouncement = computed(() =>
|
|
87
|
+
noResults.value && queryLongEnough.value ? options.noResultsText() : '',
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
watch(model, (newValue) => {
|
|
91
|
+
cancelPendingComplete();
|
|
92
|
+
// selecting a suggestion copies its text into the input; that change is
|
|
93
|
+
// terminal and must not trigger another fetch
|
|
94
|
+
if (lastSelectedText !== null && newValue === lastSelectedText) {
|
|
95
|
+
lastSelectedText = null;
|
|
96
|
+
// after a selection the empty state is the prompt, even for an app
|
|
97
|
+
// that clears its suggestions in its own select handler
|
|
98
|
+
noResults.value = false;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
lastSelectedText = null;
|
|
102
|
+
// an edit ends the staleness, whatever the app answers with: waiting for
|
|
103
|
+
// the suggestions prop to change would strand a memoised list that comes
|
|
104
|
+
// back as the same array, and the panel would never fill again
|
|
105
|
+
suggestionsStale.value = false;
|
|
106
|
+
const query = newValue.trim();
|
|
107
|
+
if (query.length < options.minChars()) {
|
|
108
|
+
// a fresh (or cleared) query starts from the prompt state, not a stale
|
|
109
|
+
// "no results" from the previous query
|
|
110
|
+
noResults.value = false;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// the edited query's search is pending: show promptText, not the previous
|
|
114
|
+
// query's "no results", until the app answers
|
|
115
|
+
noResults.value = false;
|
|
116
|
+
scheduleComplete(query);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
onUnmounted(cancelPendingComplete);
|
|
120
|
+
|
|
121
|
+
function onSelect(suggestion: EsAutocompleteSuggestion) {
|
|
122
|
+
lastSelectedText = suggestion.text;
|
|
123
|
+
suggestionsStale.value = true;
|
|
124
|
+
options.emitSelect(suggestion);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { effectiveSuggestions, noResultsAnnouncement, onSelect, panelMessage };
|
|
128
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
3
|
+
import type { EsAutocompleteSuggestion } from '../types';
|
|
4
|
+
|
|
5
|
+
// Baymard: keep the list manageable — at most this many suggestions, further
|
|
6
|
+
// reduced by the row trim. shared by both shells.
|
|
7
|
+
// https://baymard.com/research-articles/autocomplete-design
|
|
8
|
+
export const ES_AUTOCOMPLETE_MAX_VISIBLE_SUGGESTIONS = 5;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* how many suggestion rows fit the space the shell gives them: rows are uniform
|
|
12
|
+
* height, so the count is the available height over one rendered row's, and
|
|
13
|
+
* whole rows come and go as that height changes — never a scrollbar or half row.
|
|
14
|
+
*
|
|
15
|
+
* the limit comes from the container's max-height or explicit height, re-read on
|
|
16
|
+
* suggestion changes, resizes and scrolls; anything else calls `remeasure`.
|
|
17
|
+
* `beforeMeasure` runs first, so a caller positioning the container works from
|
|
18
|
+
* the same numbers, and `active` gates remeasures while a shell is closed.
|
|
19
|
+
*/
|
|
20
|
+
export function useEsAutocompleteVisibleRows(
|
|
21
|
+
contentEl: Ref<HTMLElement | null>,
|
|
22
|
+
suggestions: Ref<EsAutocompleteSuggestion[]>,
|
|
23
|
+
cap: number,
|
|
24
|
+
options: { active?: () => boolean; beforeMeasure?: () => void } = {},
|
|
25
|
+
) {
|
|
26
|
+
const visibleCount = ref(cap);
|
|
27
|
+
const measured = ref(false);
|
|
28
|
+
|
|
29
|
+
function heightLimit(element: HTMLElement): number {
|
|
30
|
+
const maxHeight = Number.parseFloat(getComputedStyle(element).maxHeight);
|
|
31
|
+
if (Number.isFinite(maxHeight)) {
|
|
32
|
+
// max-height is border-box; the rows fit inside the border
|
|
33
|
+
return maxHeight - Math.max(element.offsetHeight - element.clientHeight, 0);
|
|
34
|
+
}
|
|
35
|
+
return element.clientHeight;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function remeasure() {
|
|
39
|
+
// let any pending render flush so the measured row reflects current content
|
|
40
|
+
await nextTick();
|
|
41
|
+
const element = contentEl.value;
|
|
42
|
+
if (!element) {
|
|
43
|
+
measured.value = false;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
options.beforeMeasure?.();
|
|
47
|
+
// visibleCount never drops below 1, so a row is always rendered to measure
|
|
48
|
+
// whenever there are suggestions; a message-only panel has none to trim
|
|
49
|
+
const row = element.querySelector<HTMLElement>('[data-es-autocomplete-item]');
|
|
50
|
+
if (row) {
|
|
51
|
+
const fits = Math.floor(heightLimit(element) / row.offsetHeight);
|
|
52
|
+
visibleCount.value = Math.min(Math.max(fits, 1), cap);
|
|
53
|
+
} else {
|
|
54
|
+
visibleCount.value = cap;
|
|
55
|
+
}
|
|
56
|
+
measured.value = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// deferred a frame to coalesce event bursts to one measure per frame
|
|
60
|
+
let measureFrame: number | null = null;
|
|
61
|
+
function scheduleRemeasure() {
|
|
62
|
+
if (measureFrame !== null) {
|
|
63
|
+
cancelAnimationFrame(measureFrame);
|
|
64
|
+
}
|
|
65
|
+
measureFrame = requestAnimationFrame(() => {
|
|
66
|
+
measureFrame = null;
|
|
67
|
+
remeasure();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
watch(contentEl, (element) => {
|
|
72
|
+
if (element) {
|
|
73
|
+
scheduleRemeasure();
|
|
74
|
+
} else {
|
|
75
|
+
measured.value = false;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
watch(
|
|
80
|
+
suggestions,
|
|
81
|
+
() => {
|
|
82
|
+
if (contentEl.value) {
|
|
83
|
+
remeasure();
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
// depth 1 so apps that mutate the array in place (push/splice) are seen too
|
|
87
|
+
{ deep: 1 },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// available height tracks the viewport; re-measure when it changes
|
|
91
|
+
function onViewportChange() {
|
|
92
|
+
if (!contentEl.value || options.active?.() === false) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
scheduleRemeasure();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
onMounted(() => {
|
|
99
|
+
window.addEventListener('resize', onViewportChange);
|
|
100
|
+
// capture because scroll events do not bubble: this hears scrolls of any
|
|
101
|
+
// scrollable ancestor, not just the document
|
|
102
|
+
window.addEventListener('scroll', onViewportChange, { capture: true, passive: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
onBeforeUnmount(() => {
|
|
106
|
+
window.removeEventListener('resize', onViewportChange);
|
|
107
|
+
window.removeEventListener('scroll', onViewportChange, { capture: true });
|
|
108
|
+
if (measureFrame !== null) {
|
|
109
|
+
cancelAnimationFrame(measureFrame);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const visibleSuggestions = computed(() => suggestions.value.slice(0, visibleCount.value));
|
|
114
|
+
|
|
115
|
+
return { measured, remeasure, visibleSuggestions };
|
|
116
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface EsAutocompleteTextSegment {
|
|
2
|
+
/** true when this is text the suggestion would add beyond the typed query — render it bold */
|
|
3
|
+
predictive: boolean;
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface EsAutocompleteSuggestion<T = unknown> {
|
|
8
|
+
id: string;
|
|
9
|
+
/** full suggested query, e.g. "backpack rain cover" */
|
|
10
|
+
text: string;
|
|
11
|
+
/** opaque app payload, returned untouched on select */
|
|
12
|
+
value?: T;
|
|
13
|
+
}
|
package/app/types/index.ts
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// how long past an animation's own duration to keep waiting before giving up on
|
|
2
|
+
// it; long enough that a running animation always settles on its own
|
|
3
|
+
const DEADLINE_SLACK_MS = 150;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* awaits an animation with a deadline: resolves when it finishes, or cancels it
|
|
7
|
+
* and resolves shortly after its duration is up. never rejects, so a caller
|
|
8
|
+
* cannot hang on a hidden tab, whose animation timeline stops advancing.
|
|
9
|
+
*/
|
|
10
|
+
export function awaitAnimationOrTimeout(animation: Animation, durationMs: number) {
|
|
11
|
+
return new Promise<void>((resolve) => {
|
|
12
|
+
const timer = setTimeout(() => {
|
|
13
|
+
animation.cancel();
|
|
14
|
+
resolve();
|
|
15
|
+
}, durationMs + DEADLINE_SLACK_MS);
|
|
16
|
+
animation.finished
|
|
17
|
+
.catch(() => undefined)
|
|
18
|
+
.then(() => {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
resolve();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
});
|