@energysage/es-ds-components 6.2.0 → 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 +7 -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/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 +4 -2
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { EsAutocompleteTextSegment } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* splits a line into segments for predictive bolding, as the default item
|
|
5
|
+
* renderer does: query tokens match case-insensitively in any order, preferring
|
|
6
|
+
* word starts, and what they match is non-predictive (regular — the user typed
|
|
7
|
+
* it) while the rest is predictive (bold — what selecting would add). a blank or
|
|
8
|
+
* unmatched query returns one non-predictive segment, so nothing renders fully
|
|
9
|
+
* bold.
|
|
10
|
+
*
|
|
11
|
+
* <span
|
|
12
|
+
* v-for="(segment, index) in splitEsAutocompleteText(line, query)"
|
|
13
|
+
* :key="index"
|
|
14
|
+
* :class="{ 'font-weight-bold': segment.predictive }">
|
|
15
|
+
* {{ segment.text }}</span>
|
|
16
|
+
*
|
|
17
|
+
* this is presentation only and never decides what matches, so a backend match
|
|
18
|
+
* it cannot see (typo tolerance, synonyms) simply does not highlight. an app
|
|
19
|
+
* whose API returns its own match offsets should build segments from those in a
|
|
20
|
+
* custom `item` slot instead.
|
|
21
|
+
*/
|
|
22
|
+
export function splitEsAutocompleteText(text: string, query: string): EsAutocompleteTextSegment[] {
|
|
23
|
+
return splitEsAutocompleteTextLines([text], query)[0]!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* multi-line variant, for a suggestion rendered as several lines. bolding is
|
|
28
|
+
* decided across them together: with no match anywhere every line renders
|
|
29
|
+
* regular, and once any line matches, the rest are entirely predictive.
|
|
30
|
+
*/
|
|
31
|
+
export function splitEsAutocompleteTextLines(lines: string[], query: string): EsAutocompleteTextSegment[][] {
|
|
32
|
+
const tokens = [...new Set(query.trim().toLowerCase().split(/\s+/).filter(Boolean))];
|
|
33
|
+
|
|
34
|
+
// per token, prefer word-start matches ("st" is the "St" in "Beacon St", not
|
|
35
|
+
// "Boston"), falling back to anywhere only when none match ("3" in "123")
|
|
36
|
+
const rangesPerLine: Array<Array<[number, number]>> = lines.map(() => []);
|
|
37
|
+
for (const token of tokens) {
|
|
38
|
+
const wordStartRanges = lines.map((line) => findTokenRanges(line, token, true));
|
|
39
|
+
const chosenRanges = wordStartRanges.some((ranges) => ranges.length > 0)
|
|
40
|
+
? wordStartRanges
|
|
41
|
+
: lines.map((line) => findTokenRanges(line, token, false));
|
|
42
|
+
chosenRanges.forEach((ranges, lineIndex) => rangesPerLine[lineIndex]!.push(...ranges));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mergedPerLine = rangesPerLine.map(mergeRanges);
|
|
46
|
+
const anyLineMatches = mergedPerLine.some((ranges) => ranges.length > 0);
|
|
47
|
+
return lines.map((line, lineIndex) => {
|
|
48
|
+
const ranges = mergedPerLine[lineIndex]!;
|
|
49
|
+
if (!ranges.length) {
|
|
50
|
+
return [{ predictive: anyLineMatches, text: line }];
|
|
51
|
+
}
|
|
52
|
+
return segmentsFromRanges(line, ranges);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** [start, end) ranges where the token occurs within the text */
|
|
57
|
+
function findTokenRanges(text: string, token: string, wordStartsOnly: boolean): Array<[number, number]> {
|
|
58
|
+
// lowercasing can change a string's length (İ becomes two code units), which
|
|
59
|
+
// would misalign offsets against the original — match case-sensitively
|
|
60
|
+
// rather than mis-slice the segments
|
|
61
|
+
const lowered = text.toLowerCase();
|
|
62
|
+
const textLower = lowered.length === text.length ? lowered : text;
|
|
63
|
+
const isWordChar = (character: string | undefined) => !!character && /[\p{L}\p{N}]/u.test(character);
|
|
64
|
+
|
|
65
|
+
const ranges: Array<[number, number]> = [];
|
|
66
|
+
let index = textLower.indexOf(token);
|
|
67
|
+
while (index !== -1) {
|
|
68
|
+
if (!wordStartsOnly || !isWordChar(textLower[index - 1])) {
|
|
69
|
+
ranges.push([index, index + token.length]);
|
|
70
|
+
}
|
|
71
|
+
index = textLower.indexOf(token, index + 1);
|
|
72
|
+
}
|
|
73
|
+
return ranges;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mergeRanges(ranges: Array<[number, number]>): Array<[number, number]> {
|
|
77
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
78
|
+
const merged: Array<[number, number]> = [];
|
|
79
|
+
for (const range of ranges) {
|
|
80
|
+
const last = merged[merged.length - 1];
|
|
81
|
+
if (last && range[0] <= last[1]) {
|
|
82
|
+
last[1] = Math.max(last[1], range[1]);
|
|
83
|
+
} else {
|
|
84
|
+
merged.push([...range]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return merged;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function segmentsFromRanges(text: string, ranges: Array<[number, number]>): EsAutocompleteTextSegment[] {
|
|
91
|
+
const segments: EsAutocompleteTextSegment[] = [];
|
|
92
|
+
let position = 0;
|
|
93
|
+
for (const [start, end] of ranges) {
|
|
94
|
+
if (start > position) {
|
|
95
|
+
segments.push({ predictive: true, text: text.slice(position, start) });
|
|
96
|
+
}
|
|
97
|
+
segments.push({ predictive: false, text: text.slice(start, end) });
|
|
98
|
+
position = end;
|
|
99
|
+
}
|
|
100
|
+
if (position < text.length) {
|
|
101
|
+
segments.push({ predictive: true, text: text.slice(position) });
|
|
102
|
+
}
|
|
103
|
+
return segments;
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@energysage/es-ds-components",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "An EnergySage Vue component library",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"main": "./nuxt.config.ts",
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "
|
|
22
|
+
"test": "vitest run",
|
|
23
23
|
"lint:eslint": "eslint .",
|
|
24
24
|
"lint:prettier": "prettier --check .",
|
|
25
25
|
"lint": "npm run lint:eslint && npm run lint:prettier",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"embla-carousel-vue": "^8.6.0",
|
|
42
42
|
"eslint": "^10.2.1",
|
|
43
43
|
"eslint-config-prettier": "^10.0.1",
|
|
44
|
+
"happy-dom": "^20.10.6",
|
|
44
45
|
"html-truncate": "^1.2.2",
|
|
45
46
|
"maska": "^3.2.0",
|
|
46
47
|
"mitt": "^3.0.1",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"reka-ui": "^2.8.0",
|
|
53
54
|
"sass": "^1.77.8",
|
|
54
55
|
"typescript": "^6.0.3",
|
|
56
|
+
"vitest": "^4.1.10",
|
|
55
57
|
"vue": "^3.5.28",
|
|
56
58
|
"vue-tsc": "^3.0.8"
|
|
57
59
|
}
|