@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.
@@ -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.2.0",
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": "echo \"Error: no test specified\" && exit 1",
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
  }