@energysage/es-ds-components 6.3.1 → 6.3.2
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/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.test.ts +60 -1
- package/app/composables/autocomplete-combobox.ts +30 -11
- package/app/composables/autocomplete-search.test.ts +66 -0
- package/app/composables/autocomplete-search.ts +18 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [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
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* EsAutocomplete auto-select no longer selects from a stale list ([#1796](https://github.com/EnergySage/es-ds/issues/1796)) ([b680b58](https://github.com/EnergySage/es-ds/commit/b680b58d90ddae913c4c7e715f63d412cd0c4b4f))
|
|
9
|
+
|
|
3
10
|
## [6.3.1](https://github.com/EnergySage/es-ds/compare/es-ds-components-v6.3.0...es-ds-components-v6.3.1) (2026-10-12)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -22,6 +22,7 @@ interface Props {
|
|
|
22
22
|
state?: boolean | null;
|
|
23
23
|
suggestionCountText: (count: number) => string;
|
|
24
24
|
suggestions: EsAutocompleteSuggestion[];
|
|
25
|
+
suggestionsPending: boolean;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const props = defineProps<Props>();
|
|
@@ -140,6 +141,7 @@ const combobox = useEsAutocompleteCombobox({
|
|
|
140
141
|
model,
|
|
141
142
|
// arrows navigate exactly what is displayed; nothing while the panel is closed
|
|
142
143
|
suggestions: () => (listboxOpen.value ? visibleSuggestions.value : []),
|
|
144
|
+
suggestionsPending: () => props.suggestionsPending,
|
|
143
145
|
});
|
|
144
146
|
watch(open, combobox.resetHighlight);
|
|
145
147
|
|
|
@@ -31,6 +31,7 @@ interface Props {
|
|
|
31
31
|
state?: boolean | null;
|
|
32
32
|
suggestionCountText: (count: number) => string;
|
|
33
33
|
suggestions: EsAutocompleteSuggestion[];
|
|
34
|
+
suggestionsPending: boolean;
|
|
34
35
|
triggerDescribedBy: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -83,6 +84,7 @@ const combobox = useEsAutocompleteCombobox({
|
|
|
83
84
|
model,
|
|
84
85
|
// arrows navigate exactly what is displayed
|
|
85
86
|
suggestions: () => (takeoverOpen.value ? visibleSuggestions.value : []),
|
|
87
|
+
suggestionsPending: () => props.suggestionsPending,
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
// the takeover's input announces "expanded" exactly when a list exists
|
|
@@ -70,16 +70,17 @@ const triggerDescribedBy = computed(() => (showError.value ? `${triggerHelpId} $
|
|
|
70
70
|
|
|
71
71
|
// the debounced 'complete' contract, minChars gating, and prompt/no-results
|
|
72
72
|
// messaging live in useEsAutocompleteSearch so the contract is unit-testable
|
|
73
|
-
const { effectiveSuggestions, noResultsAnnouncement, onSelect, panelMessage } =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
const { effectiveSuggestions, noResultsAnnouncement, onSelect, panelMessage, suggestionsPending } =
|
|
74
|
+
useEsAutocompleteSearch({
|
|
75
|
+
delay: () => props.delay,
|
|
76
|
+
emitComplete: (query) => emit('complete', query),
|
|
77
|
+
emitSelect: (suggestion) => emit('select', suggestion),
|
|
78
|
+
minChars: () => props.minChars,
|
|
79
|
+
model,
|
|
80
|
+
noResultsText: () => props.noResultsText,
|
|
81
|
+
promptText: () => props.promptText,
|
|
82
|
+
suggestions: () => props.suggestions,
|
|
83
|
+
});
|
|
83
84
|
</script>
|
|
84
85
|
|
|
85
86
|
<template>
|
|
@@ -104,6 +105,7 @@ const { effectiveSuggestions, noResultsAnnouncement, onSelect, panelMessage } =
|
|
|
104
105
|
:state="state"
|
|
105
106
|
:suggestion-count-text="suggestionCountText"
|
|
106
107
|
:suggestions="effectiveSuggestions"
|
|
108
|
+
:suggestions-pending="suggestionsPending"
|
|
107
109
|
@blur="emit('blur')"
|
|
108
110
|
@select="onSelect">
|
|
109
111
|
<template
|
|
@@ -135,6 +137,7 @@ const { effectiveSuggestions, noResultsAnnouncement, onSelect, panelMessage } =
|
|
|
135
137
|
:state="state"
|
|
136
138
|
:suggestion-count-text="suggestionCountText"
|
|
137
139
|
:suggestions="effectiveSuggestions"
|
|
140
|
+
:suggestions-pending="suggestionsPending"
|
|
138
141
|
:trigger-described-by="triggerDescribedBy"
|
|
139
142
|
@blur="emit('blur')"
|
|
140
143
|
@select="onSelect">
|
|
@@ -26,6 +26,7 @@ function makeCombobox(
|
|
|
26
26
|
document.body.append(inputEl);
|
|
27
27
|
const model = ref('solar');
|
|
28
28
|
const suggestions = ref(initialSuggestions);
|
|
29
|
+
const suggestionsPending = ref(false);
|
|
29
30
|
const close = vi.fn();
|
|
30
31
|
const emitSelect = vi.fn();
|
|
31
32
|
const { result: combobox } = withSetup(() =>
|
|
@@ -38,9 +39,10 @@ function makeCombobox(
|
|
|
38
39
|
inputEl: ref(inputEl),
|
|
39
40
|
model,
|
|
40
41
|
suggestions: () => suggestions.value,
|
|
42
|
+
suggestionsPending: () => suggestionsPending.value,
|
|
41
43
|
}),
|
|
42
44
|
);
|
|
43
|
-
return { close, combobox, emitSelect, inputEl, model, suggestions };
|
|
45
|
+
return { close, combobox, emitSelect, inputEl, model, suggestions, suggestionsPending };
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function keydown(key: string, init: { isComposing?: boolean } = {}) {
|
|
@@ -462,3 +464,60 @@ describe('useEsAutocompleteCombobox automatic selection', () => {
|
|
|
462
464
|
expect(emitSelect).not.toHaveBeenCalled();
|
|
463
465
|
});
|
|
464
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
|
+
});
|
|
@@ -29,6 +29,11 @@ interface AutocompleteComboboxOptions {
|
|
|
29
29
|
* the trim, and empty while closed so keys cannot walk an invisible list
|
|
30
30
|
*/
|
|
31
31
|
suggestions: () => EsAutocompleteSuggestion[];
|
|
32
|
+
/**
|
|
33
|
+
* whether the list answers an earlier query than the one typed, because the
|
|
34
|
+
* app has yet to answer the latest edit
|
|
35
|
+
*/
|
|
36
|
+
suggestionsPending: () => boolean;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/**
|
|
@@ -49,12 +54,28 @@ export function useEsAutocompleteCombobox(options: AutocompleteComboboxOptions)
|
|
|
49
54
|
// split es-dropdown-select makes)
|
|
50
55
|
const keyboardNav = computed(() => highlightSource.value === 'keyboard');
|
|
51
56
|
|
|
52
|
-
// the suggestion
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
// whether the suggestion's text begins with what is typed, so the field can
|
|
58
|
+
// show it as the typed text with a remainder after it
|
|
59
|
+
function continuesTyped(suggestion: EsAutocompleteSuggestion) {
|
|
60
|
+
const typed = options.model.value;
|
|
61
|
+
return !!typed && suggestion.text.toLowerCase().startsWith(typed.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// automatic selection's choice: the first suggestion. a list answering an
|
|
65
|
+
// earlier query keeps it only while it continues the typed text, so Enter or
|
|
66
|
+
// leaving the field never commits a suggestion the user has typed past
|
|
67
|
+
const heldSuggestion = computed(() => {
|
|
68
|
+
const first = options.autoSelect() ? options.suggestions()[0] : undefined;
|
|
69
|
+
if (!first) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return !options.suggestionsPending() || continuesTyped(first) ? first : null;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// the suggestion the field itself follows under automatic selection, until
|
|
76
|
+
// arrowing takes the field off it. a pointer passing over the list
|
|
77
|
+
// highlights what it is over without touching what the field proposes
|
|
78
|
+
const autoSelectedSuggestion = computed(() => (keyboardNav.value ? null : heldSuggestion.value));
|
|
58
79
|
// automatic selection also holds the highlight, until the user takes it
|
|
59
80
|
const autoHighlighted = computed(() => autoSelectedSuggestion.value !== null && highlightIndex.value < 0);
|
|
60
81
|
// where the highlight actually sits: the user's, or automatic selection's
|
|
@@ -80,12 +101,10 @@ export function useEsAutocompleteCombobox(options: AutocompleteComboboxOptions)
|
|
|
80
101
|
const autoCompletion = computed(() => {
|
|
81
102
|
const typed = options.model.value;
|
|
82
103
|
const suggestion = autoSelectedSuggestion.value;
|
|
83
|
-
if (!completionAllowed.value || !
|
|
104
|
+
if (!completionAllowed.value || !suggestion || suggestion.text.length <= typed.length) {
|
|
84
105
|
return null;
|
|
85
106
|
}
|
|
86
|
-
return suggestion.text.
|
|
87
|
-
? typed + suggestion.text.slice(typed.length)
|
|
88
|
-
: null;
|
|
107
|
+
return continuesTyped(suggestion) ? typed + suggestion.text.slice(typed.length) : null;
|
|
89
108
|
});
|
|
90
109
|
|
|
91
110
|
// what the input displays: the typed query, the highlighted suggestion while
|
|
@@ -103,7 +122,7 @@ export function useEsAutocompleteCombobox(options: AutocompleteComboboxOptions)
|
|
|
103
122
|
// suggestion that does not complete the typed text changes nothing on screen,
|
|
104
123
|
// and an aria-activedescendant change alone is not reliably spoken, so
|
|
105
124
|
// without this a screen reader hears no selection at all
|
|
106
|
-
const autoSelectedText = computed(() =>
|
|
125
|
+
const autoSelectedText = computed(() => heldSuggestion.value?.text ?? '');
|
|
107
126
|
|
|
108
127
|
const listboxId = `${options.idPrefix}-listbox`;
|
|
109
128
|
function optionId(index: number) {
|
|
@@ -230,6 +230,72 @@ describe('useEsAutocompleteSearch panel message and suggestion gating', () => {
|
|
|
230
230
|
expect(search.panelMessage.value).toBe('Type for suggestions');
|
|
231
231
|
});
|
|
232
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
|
+
|
|
233
299
|
it('gates suggestions below minChars with a referentially stable empty list', async () => {
|
|
234
300
|
const { search, suggestions, type } = makeSearch({ minChars: 3 });
|
|
235
301
|
suggestions.value = [{ id: 'a', text: 'solar batteries' }];
|
|
@@ -20,8 +20,9 @@ const EMPTY_SUGGESTIONS: EsAutocompleteSuggestion[] = [];
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
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,
|
|
24
|
-
* empty-state messaging. here so it is
|
|
23
|
+
* the minChars gate, the staleness of the app's list after a selection, which
|
|
24
|
+
* query that list answers, and the empty-state messaging. here so it is
|
|
25
|
+
* unit-testable without a mount.
|
|
25
26
|
*/
|
|
26
27
|
export function useEsAutocompleteSearch(options: AutocompleteSearchOptions) {
|
|
27
28
|
const { model } = options;
|
|
@@ -48,9 +49,13 @@ export function useEsAutocompleteSearch(options: AutocompleteSearchOptions) {
|
|
|
48
49
|
searchTimeout = null;
|
|
49
50
|
}
|
|
50
51
|
}
|
|
52
|
+
// the query last sent out, recorded before the emit so an app that answers
|
|
53
|
+
// synchronously inside its handler is credited with answering it
|
|
54
|
+
let lastCompletedQuery: string | null = null;
|
|
51
55
|
function scheduleComplete(query: string) {
|
|
52
56
|
cancelPendingComplete();
|
|
53
57
|
searchTimeout = setTimeout(() => {
|
|
58
|
+
lastCompletedQuery = query;
|
|
54
59
|
options.emitComplete(query);
|
|
55
60
|
}, options.delay());
|
|
56
61
|
}
|
|
@@ -60,11 +65,16 @@ export function useEsAutocompleteSearch(options: AutocompleteSearchOptions) {
|
|
|
60
65
|
// whether the app's last update was empty, which tells "search found nothing"
|
|
61
66
|
// (noResultsText) from "nothing has answered yet" (promptText)
|
|
62
67
|
const noResults = ref(false);
|
|
68
|
+
// the query the app's list answers: the last one sent out when the list
|
|
69
|
+
// arrived. an app is expected to drop a response to an earlier query that
|
|
70
|
+
// arrives late, since nothing here can tell it apart
|
|
71
|
+
const answeredQuery = ref<string | null>(null);
|
|
63
72
|
watch(
|
|
64
73
|
options.suggestions,
|
|
65
74
|
(list) => {
|
|
66
75
|
noResults.value = list.length === 0;
|
|
67
76
|
suggestionsStale.value = false;
|
|
77
|
+
answeredQuery.value = lastCompletedQuery;
|
|
68
78
|
},
|
|
69
79
|
// depth 1 so apps that mutate the array in place (push/splice) are seen too
|
|
70
80
|
{ deep: 1 },
|
|
@@ -73,6 +83,11 @@ export function useEsAutocompleteSearch(options: AutocompleteSearchOptions) {
|
|
|
73
83
|
// what an open panel shows with nothing to render: the no-results message once
|
|
74
84
|
// a search has come back empty, otherwise the prompt
|
|
75
85
|
const queryLongEnough = computed(() => model.value.trim().length >= options.minChars());
|
|
86
|
+
|
|
87
|
+
// the typed query has moved past what the list answers, and its own search
|
|
88
|
+
// is still debouncing or in flight. the list stays on screen meanwhile, but
|
|
89
|
+
// automatic selection must not commit from it (see useEsAutocompleteCombobox)
|
|
90
|
+
const suggestionsPending = computed(() => queryLongEnough.value && model.value.trim() !== answeredQuery.value);
|
|
76
91
|
const panelMessage = computed(() => {
|
|
77
92
|
if (effectiveSuggestions.value.length) {
|
|
78
93
|
return '';
|
|
@@ -124,5 +139,5 @@ export function useEsAutocompleteSearch(options: AutocompleteSearchOptions) {
|
|
|
124
139
|
options.emitSelect(suggestion);
|
|
125
140
|
}
|
|
126
141
|
|
|
127
|
-
return { effectiveSuggestions, noResultsAnnouncement, onSelect, panelMessage };
|
|
142
|
+
return { effectiveSuggestions, noResultsAnnouncement, onSelect, panelMessage, suggestionsPending };
|
|
128
143
|
}
|