@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 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 } = useEsAutocompleteSearch({
74
- delay: () => props.delay,
75
- emitComplete: (query) => emit('complete', query),
76
- emitSelect: (suggestion) => emit('select', suggestion),
77
- minChars: () => props.minChars,
78
- model,
79
- noResultsText: () => props.noResultsText,
80
- promptText: () => props.promptText,
81
- suggestions: () => props.suggestions,
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 the field itself follows under automatic selection: the
53
- // first, and only arrowing takes the field off it. a pointer passing over the
54
- // list highlights what it is over without touching what the field proposes
55
- const autoSelectedSuggestion = computed(() =>
56
- options.autoSelect() && !keyboardNav.value ? (options.suggestions()[0] ?? null) : null,
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 || !typed || !suggestion || suggestion.text.length <= typed.length) {
104
+ if (!completionAllowed.value || !suggestion || suggestion.text.length <= typed.length) {
84
105
  return null;
85
106
  }
86
- return suggestion.text.toLowerCase().startsWith(typed.toLowerCase())
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(() => (options.autoSelect() ? (options.suggestions()[0]?.text ?? '') : ''));
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, and the
24
- * empty-state messaging. here so it is unit-testable without a mount.
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energysage/es-ds-components",
3
- "version": "6.3.1",
3
+ "version": "6.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "An EnergySage Vue component library",