@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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [6.3.3](https://github.com/EnergySage/es-ds/compare/es-ds-components-v6.3.2...es-ds-components-v6.3.3) (2026-10-19)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * exclude tests from package to prevent downstream type errors ([#1798](https://github.com/EnergySage/es-ds/issues/1798)) ([07723a3](https://github.com/EnergySage/es-ds/commit/07723a36cefd46a8eacbc2c63413a753702aa34c))
9
+
10
+ ## [6.3.2](https://github.com/EnergySage/es-ds/compare/es-ds-components-v6.3.1...es-ds-components-v6.3.2) (2026-10-14)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * 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))
16
+
3
17
  ## [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
18
 
5
19
 
@@ -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">
@@ -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) {
@@ -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/nuxt.config.ts CHANGED
@@ -109,6 +109,7 @@ export default defineNuxtConfig({
109
109
  },
110
110
  },
111
111
  },
112
+ // Applies to all consuming apps too, since layer config merges into theirs
112
113
  typescript: {
113
114
  typeCheck: true,
114
115
  },
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.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "An EnergySage Vue component library",
@@ -18,6 +18,13 @@
18
18
  "url": "https://github.com/EnergySage/es-ds/issues"
19
19
  },
20
20
  "main": "./nuxt.config.ts",
21
+ "files": [
22
+ "app",
23
+ "nuxt.config.ts",
24
+ "CHANGELOG.md",
25
+ "README.md",
26
+ "!**/*.test.ts"
27
+ ],
21
28
  "scripts": {
22
29
  "test": "vitest run",
23
30
  "lint:eslint": "eslint .",
package/.prettierignore DELETED
@@ -1,2 +0,0 @@
1
- # Managed by release-please
2
- CHANGELOG.md
package/.prettierrc.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "printWidth": 119,
3
- "useTabs": false,
4
- "singleQuote": true,
5
- "trailingComma": "all",
6
- "bracketSpacing": true,
7
- "bracketSameLine": true,
8
- "singleAttributePerLine": true,
9
- "tabWidth": 4
10
- }
@@ -1,70 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
- import type { App } from 'vue';
4
- import { createApp, nextTick, ref } from 'vue';
5
- import { useEsAutocompleteAnnouncer } from './autocomplete-announcer';
6
-
7
- /** run the composable inside a real component so watchers are scoped */
8
- function withSetup<T>(composable: () => T): { app: App; result: T } {
9
- let result!: T;
10
- const app = createApp({
11
- setup() {
12
- result = composable();
13
- return () => null;
14
- },
15
- });
16
- app.mount(document.createElement('div'));
17
- return { app, result };
18
- }
19
-
20
- function makeAnnouncer(initial = '') {
21
- const source = ref(initial);
22
- const { app, result: announced } = withSetup(() => useEsAutocompleteAnnouncer(() => source.value));
23
- return { announced, app, source };
24
- }
25
-
26
- describe('useEsAutocompleteAnnouncer', () => {
27
- beforeEach(() => vi.useFakeTimers());
28
- afterEach(() => vi.useRealTimers());
29
-
30
- it('holds the text back until the listbox has stopped changing', async () => {
31
- const { announced, source } = makeAnnouncer();
32
- source.value = '34 Prospect St';
33
- await nextTick();
34
- expect(announced.value).toBe('');
35
- vi.advanceTimersByTime(250);
36
- expect(announced.value).toBe('34 Prospect St');
37
- });
38
-
39
- it('speaks only the last of several texts arriving together', async () => {
40
- const { announced, source } = makeAnnouncer();
41
- source.value = '34 Prospect St';
42
- await nextTick();
43
- vi.advanceTimersByTime(100);
44
- source.value = '355 Cedar Ct';
45
- await nextTick();
46
- vi.advanceTimersByTime(100);
47
- expect(announced.value).toBe('');
48
- vi.advanceTimersByTime(150);
49
- expect(announced.value).toBe('355 Cedar Ct');
50
- });
51
-
52
- it('empties at once, having nothing to wait behind', async () => {
53
- const { announced, source } = makeAnnouncer();
54
- source.value = '34 Prospect St';
55
- await nextTick();
56
- vi.advanceTimersByTime(250);
57
- source.value = '';
58
- await nextTick();
59
- expect(announced.value).toBe('');
60
- });
61
-
62
- it('drops a text still waiting when the component goes away', async () => {
63
- const { announced, app, source } = makeAnnouncer();
64
- source.value = '34 Prospect St';
65
- await nextTick();
66
- app.unmount();
67
- vi.advanceTimersByTime(250);
68
- expect(announced.value).toBe('');
69
- });
70
- });