@kws3/ui 1.8.4 → 1.9.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.mdx CHANGED
@@ -1,3 +1,17 @@
1
+ ## 1.9.2
2
+ - Bug fix with fuzzy.js and Vite prebundling
3
+ - Debounce fuzzy searches
4
+ - Expose `@kws3/ui/utils/fuzzysearch` as a reusable function
5
+
6
+ ## 1.9.1
7
+ - `SearchableSelect` and `MultiSelect`: match colors of dropdown area to theme color when dropdown area is inside a `Portal`
8
+ - `AutoComplete`: match colors of dropdown area to theme color when dropdown area is inside a `Portal`
9
+
10
+ ## 1.9.0
11
+ - Use new fuzzy algorithm for `AutoComplete`,`MultiSelect` and `SearchableSelect` component
12
+ - Add property `scoreThreshold` in `AutoComplete`,`MultiSelect` and `SearchableSelect` component to control search accuracy.
13
+ - Bugfix: Keep `SubmitButton` disabled while it's not ready to submit yet
14
+
1
15
  ## 1.8.4
2
16
  - New `AutoComplete` component
3
17
  - Make options text size match the input `size` in `MultiSelect` and `SearchableSelect`.
@@ -33,7 +33,7 @@
33
33
  : tracker.saved
34
34
  ? 'is-success'
35
35
  : 'is-' + color}"
36
- {disabled}
36
+ disabled={tracker.saved || tracker.saving || tracker.error || disabled}
37
37
  data-cy={cy}>
38
38
  {#if tracker.saved}
39
39
  <Icon icon="check" size={icon_size} />
@@ -15,6 +15,7 @@ Only send this prop if you want to fetch `options` asynchronously.
15
15
  @param {'fuzzy'|'strict'} [search_strategy="fuzzy"] - Filtered options to be displayed strictly based on search text or perform a fuzzy match.
16
16
  Fuzzy match will not work if `search` function is set, as the backend service is meant to do the matching., Default: `"fuzzy"`
17
17
  @param {boolean} [highlighted_results=true] - Whether to show the highlighted or plain results in the dropdown., Default: `true`
18
+ @param {number} [scoreThreshold=5] - Score threshold for fuzzy search strategy, setting high score gives more fuzzy matches., Default: `5`
18
19
  @param {''|'small'|'medium'|'large'} [size=""] - Size of the input, Default: `""`
19
20
  @param {''|'primary'|'success'|'warning'|'info'|'danger'|'dark'|'light'} [color=""] - Color of the input, Default: `""`
20
21
  @param {string} [style=""] - Inline CSS for input container, Default: `""`
@@ -65,7 +66,9 @@ Default value: `<span>{option.label}</span>`
65
66
  class="button is-paddingless delete is-medium is-loading" />
66
67
  {/if}
67
68
  {#if rootContainer}
68
- <div class="kws-autocomplete" use:portal={dropdown_portal}>
69
+ <div
70
+ class="kws-autocomplete is-{color || 'primary'}"
71
+ use:portal={dropdown_portal}>
69
72
  <ul bind:this={dropdown} class="options" class:hidden={!show_options}>
70
73
  {#each filtered_options as option}
71
74
  <li
@@ -97,7 +100,7 @@ Default value: `<span>{option.label}</span>`
97
100
  import { debounce } from "@kws3/ui/utils";
98
101
  import { createEventDispatcher, onMount, tick } from "svelte";
99
102
  import { createPopper } from "@popperjs/core";
100
- import fuzzysearch from "@kws3/ui/utils/fuzzysearch";
103
+ import { fuzzy, fuzzysearch } from "../utils/fuzzysearch";
101
104
 
102
105
  const sameWidthPopperModifier = {
103
106
  name: "sameWidth",
@@ -154,6 +157,11 @@ Default value: `<span>{option.label}</span>`
154
157
  * Whether to show the highlighted or plain results in the dropdown.
155
158
  */
156
159
  export let highlighted_results = true;
160
+
161
+ /**
162
+ * Score threshold for fuzzy search strategy, setting high score gives more fuzzy matches.
163
+ */
164
+ export let scoreThreshold = 5;
157
165
  /**
158
166
  * Size of the input
159
167
  * @type {''|'small'|'medium'|'large'}
@@ -254,31 +262,11 @@ Default value: `<span>{option.label}</span>`
254
262
  }
255
263
 
256
264
  function triggerSearch(filters) {
257
- let cache = {};
258
- //TODO - can optimize more for very long lists
259
- filters.forEach((word, idx) => {
260
- // iterate over each word in the search query
261
- let opts = [];
262
- if (word) {
263
- opts = [...normalised_options].filter((item) => {
264
- // filter out items that don't match `filter`
265
- if (typeof item === "object" && item.value) {
266
- return typeof item.value === "string" && match(word, item.value);
267
- }
268
- });
269
- }
270
-
271
- cache[idx] = opts; // storing options to current index on cache
272
- });
273
-
274
- filtered_options = Object.values(cache) // get values from cache
275
- .flat() // flatten array
276
- .filter((v, i, self) => self.indexOf(v) === i); // remove duplicates
277
-
278
- if (highlighted_results) {
279
- filtered_options = highlightMatches(filtered_options, filters);
265
+ if (allow_fuzzy_match) {
266
+ debouncedFuzzySearch(filters, [...normalised_options]);
267
+ } else {
268
+ searchInStrictMode(filters, [...normalised_options]);
280
269
  }
281
- setOptionsVisible(true);
282
270
  }
283
271
 
284
272
  function triggerExternalSearch(filters) {
@@ -312,6 +300,17 @@ Default value: `<span>{option.label}</span>`
312
300
  modifiers: [sameWidthPopperModifier],
313
301
  });
314
302
 
303
+ if (allow_fuzzy_match && fuzzy) {
304
+ fuzzy.analyzeSubTerms = true;
305
+ fuzzy.analyzeSubTermDepth = 10;
306
+ fuzzy.highlighting.before = "";
307
+ fuzzy.highlighting.after = "";
308
+ if (highlighted_results) {
309
+ fuzzy.highlighting.before = `<span class="h">`;
310
+ fuzzy.highlighting.after = "</span>";
311
+ }
312
+ }
313
+
315
314
  //normalize value
316
315
  if (value === null || typeof value == "undefined") {
317
316
  value = null;
@@ -386,13 +385,6 @@ Default value: `<span>{option.label}</span>`
386
385
  fire("blur");
387
386
  }
388
387
 
389
- const match = (needle, haystack) => {
390
- let _hayStack = haystack.toLowerCase();
391
- return allow_fuzzy_match
392
- ? fuzzysearch(needle, _hayStack)
393
- : _hayStack.indexOf(needle) > -1;
394
- };
395
-
396
388
  const normaliseArraysToObjects = (arr) =>
397
389
  [...arr].map((item) =>
398
390
  typeof item === "object" ? item : { label: item, value: item }
@@ -404,7 +396,10 @@ Default value: `<span>{option.label}</span>`
404
396
  let common_chars = [...filters.join("")].filter(
405
397
  (v, i, self) => self.indexOf(v) === i
406
398
  );
407
- let pattern = new RegExp(`[${common_chars.join("")}]`, "gi");
399
+ let pattern = new RegExp(
400
+ `[${common_chars.join("").replace(/\\/g, "&#92;")}]`,
401
+ "gi"
402
+ );
408
403
  return options.map((item) => {
409
404
  return {
410
405
  ...item,
@@ -423,4 +418,58 @@ Default value: `<span>{option.label}</span>`
423
418
  function sanitizeFilters(v) {
424
419
  return v && v.trim() ? v.toLowerCase().trim().split(/\s+/) : [];
425
420
  }
421
+
422
+ const debouncedFuzzySearch = debounce(searchInFuzzyMode, 200, false);
423
+
424
+ function searchInFuzzyMode(filters, options) {
425
+ let cache = {};
426
+ //TODO - can optimize more for very long lists
427
+ filters.forEach((word, idx) => {
428
+ // iterate over each word in the search query
429
+ let opts = [];
430
+ if (word) {
431
+ let result = fuzzysearch(word, options, {
432
+ search_key: "label",
433
+ scoreThreshold,
434
+ });
435
+ opts = result;
436
+ }
437
+
438
+ cache[idx] = opts; // storing options to current index on cache
439
+ });
440
+ setFilteredOptions(cache);
441
+ }
442
+
443
+ function searchInStrictMode(filters, options) {
444
+ let cache = {};
445
+ filters.forEach((word, idx) => {
446
+ // iterate over each word in the search query
447
+ let opts = [];
448
+ if (word) {
449
+ opts = options.filter((item) => {
450
+ // filter out items that don't match `filter`
451
+ if (typeof item === "object" && item.value) {
452
+ return (
453
+ typeof item.value === "string" &&
454
+ item.value.toLowerCase().indexOf(word) > -1
455
+ );
456
+ }
457
+ });
458
+ }
459
+
460
+ cache[idx] = opts; // storing options to current index on cache
461
+ });
462
+ setFilteredOptions(cache, filters);
463
+ }
464
+
465
+ function setFilteredOptions(cache, filters) {
466
+ filtered_options = Object.values(cache) // get values from cache
467
+ .flat() // flatten array
468
+ .filter((v, i, self) => i === self.findIndex((t) => t.value === v.value)); // remove duplicates
469
+
470
+ if (highlighted_results && !allow_fuzzy_match) {
471
+ filtered_options = highlightMatches(filtered_options, filters);
472
+ }
473
+ setOptionsVisible(true);
474
+ }
426
475
  </script>
@@ -22,6 +22,7 @@ Only send this prop if you want to fetch `options` asynchronously.
22
22
  `options` prop will be ignored if this prop is set., Default: `null`
23
23
  @param {'fuzzy'|'strict'} [search_strategy="fuzzy"] - Filtered options to be displayed strictly based on search text or perform a fuzzy match.
24
24
  Fuzzy match will not work if `search` function is set, as the backend service is meant to do the matching., Default: `"fuzzy"`
25
+ @param {number} [scoreThreshold=3] - Score threshold for fuzzy search strategy, setting high score gives more fuzzy matches., Default: `3`
25
26
  @param {''|'small'|'medium'|'large'} [size=""] - Size of the input, Default: `""`
26
27
  @param {''|'primary'|'success'|'warning'|'info'|'danger'|'dark'|'light'} [color=""] - Color of the input, Default: `""`
27
28
  @param {string} [style=""] - Inline CSS for input container, Default: `""`
@@ -119,7 +120,9 @@ Default value: `<span>{option[search_key] || option}</span>`
119
120
  style={shouldShowClearAll ? "" : "display: none;"} />
120
121
  {/if}
121
122
  {#if rootContainer}
122
- <div class="kws-searchableselect" use:portal={dropdown_portal}>
123
+ <div
124
+ class="kws-searchableselect is-{color || 'primary'}"
125
+ use:portal={dropdown_portal}>
123
126
  <ul
124
127
  bind:this={dropdown}
125
128
  class="options {single ? 'is-single' : 'is-multi'}"
@@ -161,7 +164,7 @@ Default value: `<span>{option[search_key] || option}</span>`
161
164
  import { debounce } from "@kws3/ui/utils";
162
165
  import { createEventDispatcher, onMount, tick } from "svelte";
163
166
  import { createPopper } from "@popperjs/core";
164
- import fuzzysearch from "@kws3/ui/utils/fuzzysearch";
167
+ import { fuzzy, fuzzysearch } from "../../utils/fuzzysearch";
165
168
 
166
169
  const sameWidthPopperModifier = {
167
170
  name: "sameWidth",
@@ -230,6 +233,11 @@ Default value: `<span>{option[search_key] || option}</span>`
230
233
  * @type {'fuzzy'|'strict'}
231
234
  */
232
235
  export let search_strategy = "fuzzy";
236
+
237
+ /**
238
+ * Score threshold for fuzzy search strategy, setting high score gives more fuzzy matches.
239
+ */
240
+ export let scoreThreshold = 3;
233
241
  /**
234
242
  * Size of the input
235
243
  * @type {''|'small'|'medium'|'large'}
@@ -357,11 +365,7 @@ Default value: `<span>{option[search_key] || option}</span>`
357
365
 
358
366
  $: value, single, fillSelectedOptions();
359
367
 
360
- $: if (
361
- (activeOption && !filteredOptions.includes(activeOption)) ||
362
- (!activeOption && searchText)
363
- )
364
- activeOption = filteredOptions[0];
368
+ $: activeOption, searchText, filteredOptions, updateActiveOption();
365
369
 
366
370
  //TODO: optimise isSelected function
367
371
  $: isSelected = (option) => {
@@ -402,23 +406,30 @@ Default value: `<span>{option[search_key] || option}</span>`
402
406
  if (asyncMode && searching) {
403
407
  debouncedTriggerSearch(filter);
404
408
  } else {
405
- filteredOptions = normalisedOptions.slice().filter((item) => {
406
- // filter out items that don't match `filter`
407
- if (typeof item === "object") {
408
- if (used_search_key) {
409
- return (
410
- typeof item[used_search_key] === "string" &&
411
- match(filter, item[used_search_key])
412
- );
413
- } else {
414
- for (var key in item) {
415
- return typeof item[key] === "string" && match(filter, item[key]);
416
- }
417
- }
418
- } else {
419
- return match(filter, item);
420
- }
421
- });
409
+ if (allow_fuzzy_match) {
410
+ fuzzySearch(filter, [...normalisedOptions]);
411
+ } else {
412
+ filteredOptions = strictSearch(filter, [...normalisedOptions]);
413
+ }
414
+ }
415
+ }
416
+
417
+ function updateActiveOption() {
418
+ if (
419
+ (activeOption &&
420
+ searching &&
421
+ !filteredOptions.some(
422
+ (fo) => fo[used_value_key] === activeOption[used_value_key]
423
+ )) ||
424
+ (!activeOption && searchText)
425
+ ) {
426
+ activeOption = filteredOptions[0];
427
+ } else {
428
+ if (allow_fuzzy_match) {
429
+ activeOption = filteredOptions.find((opts) =>
430
+ matchesValue(activeOption, opts)
431
+ );
432
+ }
422
433
  }
423
434
  }
424
435
 
@@ -474,6 +485,13 @@ Default value: `<span>{option[search_key] || option}</span>`
474
485
  modifiers: [sameWidthPopperModifier],
475
486
  });
476
487
 
488
+ if (allow_fuzzy_match && fuzzy) {
489
+ fuzzy.analyzeSubTerms = true;
490
+ fuzzy.analyzeSubTermDepth = 10;
491
+ fuzzy.highlighting.before = "";
492
+ fuzzy.highlighting.after = "";
493
+ }
494
+
477
495
  //normalize value for single versus multiselect
478
496
  if (value === null || typeof value == "undefined") {
479
497
  value = single ? null : [];
@@ -674,16 +692,15 @@ Default value: `<span>{option[search_key] || option}</span>`
674
692
  if (_value === null || typeof _value == "undefined") {
675
693
  return false;
676
694
  }
677
- return (
678
- `${_value[used_value_key] || _value}` === `${_option[used_value_key]}`
679
- );
695
+ let value =
696
+ typeof _value === "object" && `${used_value_key}` in _value
697
+ ? _value[used_value_key]
698
+ : _value;
699
+ return `${value}` === `${_option[used_value_key]}`;
680
700
  };
681
701
 
682
702
  const match = (needle, haystack) => {
683
- let _hayStack = haystack.toLowerCase();
684
- return allow_fuzzy_match
685
- ? fuzzysearch(needle, _hayStack)
686
- : _hayStack.indexOf(needle) > -1;
703
+ return haystack.toLowerCase().indexOf(needle) > -1;
687
704
  };
688
705
 
689
706
  const normaliseArraysToObjects = (arr) => {
@@ -704,4 +721,40 @@ Default value: `<span>{option[search_key] || option}</span>`
704
721
  searching = false;
705
722
  });
706
723
  };
724
+
725
+ const fuzzySearch = debounce(searchInFuzzyMode, 200, false);
726
+
727
+ function searchInFuzzyMode(filter, options) {
728
+ if (!filter) {
729
+ filteredOptions = options;
730
+ return;
731
+ }
732
+ if (options.length) {
733
+ let result = fuzzysearch(filter, options, {
734
+ search_key: used_search_key,
735
+ scoreThreshold,
736
+ });
737
+ filteredOptions = result;
738
+ }
739
+ }
740
+
741
+ function strictSearch(filter, options) {
742
+ return options.filter((item) => {
743
+ // filter out items that don't match `filter`
744
+ if (typeof item === "object") {
745
+ if (used_search_key) {
746
+ return (
747
+ typeof item[used_search_key] === "string" &&
748
+ match(filter, item[used_search_key])
749
+ );
750
+ } else {
751
+ for (var key in item) {
752
+ return typeof item[key] === "string" && match(filter, item[key]);
753
+ }
754
+ }
755
+ } else {
756
+ return match(filter, item);
757
+ }
758
+ });
759
+ }
707
760
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kws3/ui",
3
- "version": "1.8.4",
3
+ "version": "1.9.2",
4
4
  "description": "UI components for use with Svelte v3 applications.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -29,5 +29,5 @@
29
29
  "text-mask-core": "^5.1.2",
30
30
  "tippy.js": "^6.3.1"
31
31
  },
32
- "gitHead": "440bdaf5e02a0a7ab0b69c2122c2c56da3485c1f"
32
+ "gitHead": "9e6e5f8d4dbb1e5f0a40e3dade66bdfd5f31fd4d"
33
33
  }
@@ -187,7 +187,7 @@ $kws-select-options-z-index: $__modal-z + 1 !default;
187
187
  $color-invert: nth($pair, 2);
188
188
  $color-light: findLightColor($color);
189
189
  $color-dark: findDarkColor($color);
190
- .searchableselect {
190
+ .kws-searchableselect {
191
191
  &.is-#{$name} {
192
192
  border-color: $color;
193
193
  &:focus-within {
package/utils/fuzzy.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * fuzzy.js v0.1.0
3
+ * (c) 2016 Ben Ripkens
4
+ * @license: MIT
5
+ */
6
+ /**
7
+ *
8
+ * Adapted from fuzzy.js for @kws3/ui to work with vite prebundling
9
+ */
10
+
11
+ var fuzzy = function fuzzy(term, query) {
12
+ var max = calcFuzzyScore(term, query);
13
+ var termLength = term.length;
14
+
15
+ if (fuzzy.analyzeSubTerms) {
16
+ for (var i = 1; i < termLength && i < fuzzy.analyzeSubTermDepth; i++) {
17
+ var subTerm = term.substring(i);
18
+ var score = calcFuzzyScore(subTerm, query);
19
+ if (score.score > max.score) {
20
+ // we need to correct 'term' and 'matchedTerm', as calcFuzzyScore
21
+ // does not now that it operates on a substring. Doing it only for
22
+ // new maximum score to save some performance.
23
+ score.term = term;
24
+ score.highlightedTerm = term.substring(0, i) + score.highlightedTerm;
25
+ max = score;
26
+ }
27
+ }
28
+ }
29
+
30
+ return max;
31
+ };
32
+
33
+ var calcFuzzyScore = function calcFuzzyScore(term, query) {
34
+ var score = 0;
35
+ var termLength = term.length;
36
+ var queryLength = query.length;
37
+ var highlighting = "";
38
+ var ti = 0;
39
+ // -1 would not work as this would break the calculations of bonus
40
+ // points for subsequent character matches. Something like
41
+ // Number.MIN_VALUE would be more appropriate, but unfortunately
42
+ // Number.MIN_VALUE + 1 equals 1...
43
+ var previousMatchingCharacter = -2;
44
+
45
+ for (var qi = 0; qi < queryLength && ti < termLength; qi++) {
46
+ var qc = query.charAt(qi);
47
+ var lowerQc = qc.toLowerCase();
48
+
49
+ for (; ti < termLength; ti++) {
50
+ var tc = term.charAt(ti);
51
+
52
+ if (lowerQc === tc.toLowerCase()) {
53
+ score++;
54
+
55
+ if (previousMatchingCharacter + 1 === ti) {
56
+ score += 2;
57
+ }
58
+
59
+ highlighting +=
60
+ fuzzy.highlighting.before + tc + fuzzy.highlighting.after;
61
+ previousMatchingCharacter = ti;
62
+ ti++;
63
+ break;
64
+ } else {
65
+ highlighting += tc;
66
+ }
67
+ }
68
+ }
69
+
70
+ highlighting += term.substring(ti, term.length);
71
+
72
+ return {
73
+ score: score,
74
+ term: term,
75
+ query: query,
76
+ highlightedTerm: highlighting,
77
+ };
78
+ };
79
+
80
+ fuzzy.matchComparator = function matchComparator(m1, m2) {
81
+ return m2.score - m1.score !== 0
82
+ ? m2.score - m1.score
83
+ : m1.term.length - m2.term.length;
84
+ };
85
+
86
+ /*
87
+ * Whether or not fuzzy.js should analyze sub-terms, i.e. also
88
+ * check term starting positions != 0.
89
+ *
90
+ * Example:
91
+ * Given the term 'Halleluja' and query 'luja'
92
+ *
93
+ * Fuzzy.js scores this combination with an 8, when analyzeSubTerms is
94
+ * set to false, as the following matching string will be calculated:
95
+ * Ha[l]lel[uja]
96
+ *
97
+ * If you activate sub temr analysis though, the query will reach a score
98
+ * of 10, as the matching string looks as following:
99
+ * Halle[luja]
100
+ *
101
+ * Naturally, the second version is more expensive than the first one.
102
+ * You should therefore configure how many sub terms you which to analyse.
103
+ * This can be configured through fuzzy.analyzeSubTermDepth = 10.
104
+ */
105
+ fuzzy.analyzeSubTerms = false;
106
+
107
+ /*
108
+ * How many sub terms should be analyzed.
109
+ */
110
+ fuzzy.analyzeSubTermDepth = 10;
111
+
112
+ fuzzy.highlighting = {
113
+ before: "<em>",
114
+ after: "</em>",
115
+ };
116
+
117
+ export default fuzzy;
@@ -1,20 +1,42 @@
1
- export default function fuzzysearch(needle, haystack) {
2
- var tlen = haystack.length;
3
- var qlen = needle.length;
4
- if (qlen > tlen) {
5
- return false;
6
- }
7
- if (qlen === tlen) {
8
- return needle === haystack;
9
- }
10
- outer: for (var i = 0, j = 0; i < qlen; i++) {
11
- var nch = needle.charCodeAt(i);
12
- while (j < tlen) {
13
- if (haystack.charCodeAt(j++) === nch) {
14
- continue outer;
1
+ import fuzzy from "./fuzzy.js";
2
+
3
+ export function fuzzysearch(needle, haystack, opts) {
4
+ let search_key = defaultValue(opts, "search_key", "value");
5
+ let scoreThreshold = defaultValue(opts, "scoreThreshold", 5);
6
+
7
+ let OPTS = haystack.map((option) => {
8
+ let item = { ...option };
9
+ item.original = { ...option };
10
+ if (typeof item === "object") {
11
+ if (!Array.isArray(search_key)) {
12
+ search_key = [search_key];
15
13
  }
14
+
15
+ search_key.forEach((s_key) => {
16
+ if (`${s_key}` in item) {
17
+ let output = fuzzy(option[s_key], needle);
18
+ item.original[s_key] = output.highlightedTerm;
19
+ item.score =
20
+ !item.score || (item.score && item.score < output.score)
21
+ ? output.score
22
+ : item.score || 0;
23
+ }
24
+ });
16
25
  }
17
- return false;
18
- }
19
- return true;
26
+ return item;
27
+ });
28
+
29
+ let maxScore = Math.max(...OPTS.map((i) => i.score));
30
+ let calculatedLimit = maxScore - scoreThreshold;
31
+
32
+ OPTS = OPTS.filter(
33
+ (r) => r.score > (calculatedLimit > 0 ? calculatedLimit : 0)
34
+ );
35
+ return OPTS.map((i) => i.original);
20
36
  }
37
+
38
+ function defaultValue(opts, key, value) {
39
+ return opts && opts[key] ? opts[key] : value;
40
+ }
41
+
42
+ export { fuzzy };