@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
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 } =
|
|
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">
|
|
@@ -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) {
|
|
@@ -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
|
}
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@energysage/es-ds-components",
|
|
3
|
-
"version": "6.3.
|
|
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
package/.prettierrc.json
DELETED
|
@@ -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
|
-
});
|