@kws3/ui 1.8.3 → 1.9.1

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.1
2
+ - `SearchableSelect` and `MultiSelect`: match colors of dropdown area to theme color when dropdown area is inside a `Portal`
3
+ - `AutoComplete`: match colors of dropdown area to theme color when dropdown area is inside a `Portal`
4
+
5
+ ## 1.9.0
6
+ - Use new fuzzy algorithm for `AutoComplete`,`MultiSelect` and `SearchableSelect` component
7
+ - Add property `scoreThreshold` in `AutoComplete`,`MultiSelect` and `SearchableSelect` component to control search accuracy.
8
+ - Bugfix: Keep `SubmitButton` disabled while it's not ready to submit yet
9
+
10
+ ## 1.8.4
11
+ - New `AutoComplete` component
12
+ - Make options text size match the input `size` in `MultiSelect` and `SearchableSelect`.
13
+ - Prevent default arrow up/down behaviour on `MultiSelect` and `SearchableSelect` when options dropdown is open.
14
+
1
15
  ## 1.8.3
2
16
  - Allow `clickableRows` and `bulk_actions` to work at the same time on `GridView`
3
17
  - Various bugfixes on `GridRow`
@@ -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} />
@@ -0,0 +1,470 @@
1
+ <!--
2
+ @component
3
+
4
+
5
+ @param {string} [value=""] - Value of the Input
6
+
7
+ This property can be bound to, to fetch the current value, Default: `""`
8
+ @param {string} [placeholder=""] - Placeholder text for the input, Default: `""`
9
+ @param {array} [options=[]] - Array of strings, or objects.
10
+ Used to populate the list of options in the dropdown, Default: `[]`
11
+ @param {function|null} [search=null] - Async function to fetch options
12
+
13
+ Only send this prop if you want to fetch `options` asynchronously.
14
+ `options` prop will be ignored if this prop is set., Default: `null`
15
+ @param {'fuzzy'|'strict'} [search_strategy="fuzzy"] - Filtered options to be displayed strictly based on search text or perform a fuzzy match.
16
+ Fuzzy match will not work if `search` function is set, as the backend service is meant to do the matching., Default: `"fuzzy"`
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`
19
+ @param {''|'small'|'medium'|'large'} [size=""] - Size of the input, Default: `""`
20
+ @param {''|'primary'|'success'|'warning'|'info'|'danger'|'dark'|'light'} [color=""] - Color of the input, Default: `""`
21
+ @param {string} [style=""] - Inline CSS for input container, Default: `""`
22
+ @param {boolean} [readonly=false] - Marks component as read-only, Default: `false`
23
+ @param {boolean} [disabled=false] - Disables the component, Default: `false`
24
+ @param {HTMLElement|string} [dropdown_portal=undefined] - Where to render the dropdown list.
25
+ Can be a DOM element or a `string` with the CSS selector of the element.
26
+
27
+ By default it renders in a custom container appended to `document.body`., Default: `undefined`
28
+ @param {string} [class=""] - CSS classes for input container, Default: `""`
29
+
30
+ ### Events
31
+ - `change`
32
+ - `blur` - Triggered when the input loses focus
33
+
34
+ ### Slots
35
+ - `<slot name="default" {option} />` - Slot containing text for each selectable item
36
+
37
+ Default value: `<span>{option.label}</span>`
38
+
39
+ -->
40
+ <div
41
+ bind:this={el}
42
+ class="
43
+ kws-autocomplete input
44
+ {disabled ? 'is-disabled' : ''}
45
+ {readonly ? 'is-readonly' : ''}
46
+ is-{size} is-{color} {klass}
47
+ "
48
+ class:readonly
49
+ {style}
50
+ on:click|stopPropagation={() => input && input.focus()}>
51
+ <input
52
+ class="input is-{size}"
53
+ bind:this={input}
54
+ autocomplete="off"
55
+ {disabled}
56
+ {readonly}
57
+ bind:value
58
+ on:keydown={handleKeydown}
59
+ on:blur={blurEvent}
60
+ on:blur={() => setOptionsVisible(false)}
61
+ {placeholder} />
62
+ {#if search && options_loading}
63
+ <button
64
+ type="button"
65
+ style="border: none;"
66
+ class="button is-paddingless delete is-medium is-loading" />
67
+ {/if}
68
+ {#if rootContainer}
69
+ <div
70
+ class="kws-autocomplete is-{color || 'primary'}"
71
+ use:portal={dropdown_portal}>
72
+ <ul bind:this={dropdown} class="options" class:hidden={!show_options}>
73
+ {#each filtered_options as option}
74
+ <li
75
+ on:mousedown|preventDefault|stopPropagation={() =>
76
+ handleOptionMouseDown(option)}
77
+ on:mouseenter|preventDefault|stopPropagation={() => {
78
+ active_option = option;
79
+ }}
80
+ class="is-size-{list_text_size[size]}"
81
+ class:active={active_option === option}>
82
+ <!--
83
+ Slot containing text for each selectable item
84
+
85
+ Default value: `<span>{option.label}</span>`
86
+ -->
87
+ <slot {option}>
88
+ <!-- eslint-disable-next-line @ota-meshi/svelte/no-at-html-tags -->
89
+ {@html option.label}
90
+ </slot>
91
+ </li>
92
+ {/each}
93
+ </ul>
94
+ </div>
95
+ {/if}
96
+ </div>
97
+
98
+ <script>
99
+ import { portal } from "@kws3/ui";
100
+ import { debounce } from "@kws3/ui/utils";
101
+ import { createEventDispatcher, onMount, tick } from "svelte";
102
+ import { createPopper } from "@popperjs/core";
103
+ import fuzzy from "fuzzy.js";
104
+
105
+ const sameWidthPopperModifier = {
106
+ name: "sameWidth",
107
+ enabled: true,
108
+ phase: "beforeWrite",
109
+ requires: ["computeStyles"],
110
+ fn: ({ state }) => {
111
+ state.styles.popper.width = `${Math.max(
112
+ 200,
113
+ state.rects.reference.width
114
+ )}px`;
115
+ },
116
+ effect: ({ state }) => {
117
+ state.elements.popper.style.width = `${state.elements.reference.offsetWidth}px`;
118
+ },
119
+ };
120
+
121
+ const rootContainerId = "kws-overlay-root";
122
+
123
+ /**
124
+ * Value of the Input
125
+ *
126
+ * This property can be bound to, to fetch the current value
127
+ */
128
+ export let value = "";
129
+
130
+ /**
131
+ * Placeholder text for the input
132
+ */
133
+ export let placeholder = "";
134
+ /**
135
+ * Array of strings, or objects.
136
+ * Used to populate the list of options in the dropdown
137
+ */
138
+ export let options = [];
139
+ /**
140
+ * Async function to fetch options
141
+ *
142
+ * Only send this prop if you want to fetch `options` asynchronously.
143
+ * `options` prop will be ignored if this prop is set.
144
+ *
145
+ * @type {function|null}
146
+ */
147
+ export let search = null;
148
+
149
+ /**
150
+ * Filtered options to be displayed strictly based on search text or perform a fuzzy match.
151
+ * Fuzzy match will not work if `search` function is set, as the backend service is meant to do the matching.
152
+ * @type {'fuzzy'|'strict'}
153
+ */
154
+ export let search_strategy = "fuzzy";
155
+
156
+ /**
157
+ * Whether to show the highlighted or plain results in the dropdown.
158
+ */
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;
165
+ /**
166
+ * Size of the input
167
+ * @type {''|'small'|'medium'|'large'}
168
+ */
169
+ export let size = "";
170
+ /**
171
+ * Color of the input
172
+ * @type {''|'primary'|'success'|'warning'|'info'|'danger'|'dark'|'light'}
173
+ */
174
+ export let color = "";
175
+ /**
176
+ * Inline CSS for input container
177
+ */
178
+ export let style = "";
179
+ /**
180
+ * Marks component as read-only
181
+ */
182
+ export let readonly = false;
183
+ /**
184
+ * Disables the component
185
+ */
186
+ export let disabled = false;
187
+
188
+ /**
189
+ * Where to render the dropdown list.
190
+ * Can be a DOM element or a `string` with the CSS selector of the element.
191
+ *
192
+ * By default it renders in a custom container appended to `document.body`.
193
+ *
194
+ * @type { HTMLElement|string}
195
+ */
196
+ export let dropdown_portal = "#" + rootContainerId;
197
+
198
+ /**
199
+ * CSS classes for input container
200
+ */
201
+ let klass = "";
202
+ export { klass as class };
203
+
204
+ if (!search && (!options || !options.length))
205
+ console.error(`Missing options`);
206
+
207
+ //ensure we have a root container for all our hoisitng related stuff
208
+
209
+ let rootContainer = document.getElementById(rootContainerId);
210
+ if (!rootContainer) {
211
+ rootContainer = document.createElement("div");
212
+ rootContainer.id = rootContainerId;
213
+ document.body.appendChild(rootContainer);
214
+ }
215
+
216
+ const fire = createEventDispatcher();
217
+
218
+ let el, //whole wrapping element
219
+ dropdown, //dropdown ul
220
+ input, //the textbox to type in
221
+ POPPER,
222
+ active_option = "",
223
+ searching = true,
224
+ show_options = false,
225
+ filtered_options = [], //list of options filtered by search query
226
+ normalised_options = [], //list of options normalised
227
+ options_loading = false, //indictaes whether async search function is running
228
+ mounted = false; //indicates whether component is mounted
229
+
230
+ let list_text_size = {
231
+ small: "7",
232
+ medium: "5",
233
+ large: "4",
234
+ };
235
+
236
+ $: asyncMode = search && typeof search === "function";
237
+
238
+ $: options, normaliseOptions();
239
+ $: searching, updateFilteredOptions(value);
240
+
241
+ $: allow_fuzzy_match = !search && search_strategy === "fuzzy";
242
+
243
+ //convert arrays of strings into normalised arrays of objects
244
+ function normaliseOptions() {
245
+ let _items = options || [];
246
+ if (!_items || !(_items instanceof Array)) {
247
+ normalised_options = [];
248
+ return;
249
+ }
250
+
251
+ normalised_options = normaliseArraysToObjects(_items);
252
+ }
253
+
254
+ function updateFilteredOptions(value) {
255
+ if (!mounted) return;
256
+
257
+ if (asyncMode) {
258
+ searching && debouncedTriggerSearch(sanitizeFilters(value));
259
+ } else {
260
+ searching && triggerSearch(sanitizeFilters(value));
261
+ }
262
+ }
263
+
264
+ function triggerSearch(filters) {
265
+ let cache = {};
266
+ //TODO - can optimize more for very long lists
267
+ filters.forEach((word, idx) => {
268
+ // iterate over each word in the search query
269
+ let opts = [];
270
+ if (word) {
271
+ if (allow_fuzzy_match) {
272
+ opts = fuzzySearch(word, normalised_options);
273
+ } else {
274
+ opts = [...normalised_options].filter((item) => {
275
+ // filter out items that don't match `filter`
276
+ if (typeof item === "object" && item.value) {
277
+ return (
278
+ typeof item.value === "string" &&
279
+ item.value.toLowerCase().indexOf(word) > -1
280
+ );
281
+ }
282
+ });
283
+ }
284
+ }
285
+
286
+ cache[idx] = opts; // storing options to current index on cache
287
+ });
288
+
289
+ filtered_options = Object.values(cache) // get values from cache
290
+ .flat() // flatten array
291
+ .filter((v, i, self) => i === self.findIndex((t) => t.value === v.value)); // remove duplicates
292
+
293
+ if (highlighted_results && !allow_fuzzy_match) {
294
+ filtered_options = highlightMatches(filtered_options, filters);
295
+ }
296
+ setOptionsVisible(true);
297
+ }
298
+
299
+ function triggerExternalSearch(filters) {
300
+ if (!filters.length) {
301
+ //do not trigger async search if filters are empty
302
+ clearDropDownResults();
303
+ return;
304
+ }
305
+ options_loading = true;
306
+ // filtered_options = [];
307
+ search(filters).then((_options) => {
308
+ searching = false;
309
+ options_loading = false;
310
+ tick().then(() => {
311
+ filtered_options = normaliseArraysToObjects(_options);
312
+
313
+ if (highlighted_results) {
314
+ filtered_options = highlightMatches(filtered_options, filters);
315
+ }
316
+ setOptionsVisible(true);
317
+ });
318
+ });
319
+ }
320
+
321
+ const debouncedTriggerSearch = debounce(triggerExternalSearch, 150, false);
322
+
323
+ onMount(() => {
324
+ POPPER = createPopper(el, dropdown, {
325
+ strategy: "fixed",
326
+ placement: "bottom-start",
327
+ modifiers: [sameWidthPopperModifier],
328
+ });
329
+
330
+ if (allow_fuzzy_match && fuzzy) {
331
+ fuzzy.analyzeSubTerms = true;
332
+ fuzzy.analyzeSubTermDepth = 10;
333
+ fuzzy.highlighting.before = "";
334
+ fuzzy.highlighting.after = "";
335
+ if (highlighted_results) {
336
+ fuzzy.highlighting.before = `<span class="h">`;
337
+ fuzzy.highlighting.after = "</span>";
338
+ }
339
+ }
340
+
341
+ //normalize value
342
+ if (value === null || typeof value == "undefined") {
343
+ value = null;
344
+ }
345
+ mounted = true;
346
+
347
+ return () => {
348
+ POPPER.destroy();
349
+ };
350
+ });
351
+
352
+ function setOptionsVisible(show) {
353
+ if (readonly || disabled || show === show_options) return;
354
+ if (!value || !filtered_options.length) {
355
+ show = false;
356
+ }
357
+ show_options = show;
358
+ if (!show) {
359
+ clearDropDownResults();
360
+ }
361
+ POPPER && POPPER.update();
362
+ }
363
+
364
+ function handleKeydown(event) {
365
+ if (event.key === `Enter`) {
366
+ show_options && event.preventDefault();
367
+
368
+ if (active_option) {
369
+ handleOptionMouseDown(active_option);
370
+ } else {
371
+ // no active option means no option is selected and the actual value should be what typed in input.
372
+ setOptionsVisible(false);
373
+ }
374
+ } else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
375
+ show_options && event.preventDefault();
376
+
377
+ const increment = event.key === `ArrowUp` ? -1 : 1;
378
+ const newActiveIdx = filtered_options.indexOf(active_option) + increment;
379
+
380
+ if (newActiveIdx < 0) {
381
+ active_option = filtered_options[filtered_options.length - 1];
382
+ } else {
383
+ if (newActiveIdx === filtered_options.length)
384
+ active_option = filtered_options[0];
385
+ else active_option = filtered_options[newActiveIdx];
386
+ }
387
+ } else {
388
+ active_option = "";
389
+ searching = true;
390
+ }
391
+ }
392
+
393
+ function handleOptionMouseDown(option) {
394
+ add(option);
395
+ }
396
+
397
+ function add(token) {
398
+ if (readonly || disabled) {
399
+ return;
400
+ }
401
+
402
+ value = token.value;
403
+ fire("change", { token, type: `add` });
404
+
405
+ setOptionsVisible(false);
406
+ }
407
+
408
+ function blurEvent() {
409
+ /**
410
+ * Triggered when the input loses focus
411
+ */
412
+ fire("blur");
413
+ }
414
+
415
+ const normaliseArraysToObjects = (arr) =>
416
+ [...arr].map((item) =>
417
+ typeof item === "object" ? item : { label: item, value: item }
418
+ );
419
+
420
+ const highlightMatches = (options, filters) => {
421
+ if (!filters.length) return options;
422
+ // join all filter parts and split into chars and filter out duplicates
423
+ let common_chars = [...filters.join("")].filter(
424
+ (v, i, self) => self.indexOf(v) === i
425
+ );
426
+ let pattern = new RegExp(
427
+ `[${common_chars.join("").replace(/\\/g, "&#92;")}]`,
428
+ "gi"
429
+ );
430
+ return options.map((item) => {
431
+ return {
432
+ ...item,
433
+ label: item.value.replace(
434
+ pattern,
435
+ (match) => `<span class="h">${match}</span>`
436
+ ),
437
+ };
438
+ });
439
+ };
440
+
441
+ const clearDropDownResults = () => {
442
+ filtered_options = [];
443
+ searching = false;
444
+ };
445
+ function sanitizeFilters(v) {
446
+ return v && v.trim() ? v.toLowerCase().trim().split(/\s+/) : [];
447
+ }
448
+
449
+ function fuzzySearch(word, options) {
450
+ let OPTS = options.map((item) => {
451
+ let output = fuzzy(item.value, word);
452
+ item = { ...output, ...item };
453
+ item.label = output.highlightedTerm;
454
+ item.score =
455
+ !item.score || (item.score && item.score < output.score)
456
+ ? output.score
457
+ : item.score || 0;
458
+ return item;
459
+ });
460
+
461
+ let maxScore = Math.max(...OPTS.map((i) => i.score));
462
+ let calculatedLimit = maxScore - scoreThreshold;
463
+
464
+ OPTS = OPTS.filter(
465
+ (r) => r.score > (calculatedLimit > 0 ? calculatedLimit : 0)
466
+ );
467
+
468
+ return OPTS;
469
+ }
470
+ </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'}"
@@ -131,6 +134,7 @@ Default value: `<span>{option[search_key] || option}</span>`
131
134
  on:mouseenter|preventDefault|stopPropagation={() => {
132
135
  activeOption = option;
133
136
  }}
137
+ class="is-size-{list_text_size[size]}"
134
138
  class:selected={isSelected(option)}
135
139
  class:active={activeOption === option}>
136
140
  <span class="kws-selected-icon"
@@ -145,7 +149,7 @@ Default value: `<span>{option[search_key] || option}</span>`
145
149
  </li>
146
150
  {:else}
147
151
  {#if !options_loading}
148
- <li class="no-options">
152
+ <li class="no-options is-size-{list_text_size[size]}">
149
153
  {searchText ? no_options_msg : async_search_prompt}
150
154
  </li>
151
155
  {/if}
@@ -160,7 +164,7 @@ Default value: `<span>{option[search_key] || option}</span>`
160
164
  import { debounce } from "@kws3/ui/utils";
161
165
  import { createEventDispatcher, onMount, tick } from "svelte";
162
166
  import { createPopper } from "@popperjs/core";
163
- import fuzzysearch from "@kws3/ui/utils/fuzzysearch";
167
+ import fuzzy from "fuzzy.js";
164
168
 
165
169
  const sameWidthPopperModifier = {
166
170
  name: "sameWidth",
@@ -229,6 +233,11 @@ Default value: `<span>{option[search_key] || option}</span>`
229
233
  * @type {'fuzzy'|'strict'}
230
234
  */
231
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;
232
241
  /**
233
242
  * Size of the input
234
243
  * @type {''|'small'|'medium'|'large'}
@@ -322,6 +331,12 @@ Default value: `<span>{option[search_key] || option}</span>`
322
331
  selectedOptions = [], //list of options that are selected
323
332
  options_loading = false; //indictaes whether async search function is running
324
333
 
334
+ let list_text_size = {
335
+ small: "7",
336
+ medium: "5",
337
+ large: "4",
338
+ };
339
+
325
340
  $: single = max === 1;
326
341
  $: asyncMode = search && typeof search === "function";
327
342
  $: hasValue = single
@@ -350,11 +365,7 @@ Default value: `<span>{option[search_key] || option}</span>`
350
365
 
351
366
  $: value, single, fillSelectedOptions();
352
367
 
353
- $: if (
354
- (activeOption && !filteredOptions.includes(activeOption)) ||
355
- (!activeOption && searchText)
356
- )
357
- activeOption = filteredOptions[0];
368
+ $: activeOption, searchText, filteredOptions, updateActiveOption();
358
369
 
359
370
  //TODO: optimise isSelected function
360
371
  $: isSelected = (option) => {
@@ -395,23 +406,26 @@ Default value: `<span>{option[search_key] || option}</span>`
395
406
  if (asyncMode && searching) {
396
407
  debouncedTriggerSearch(filter);
397
408
  } else {
398
- filteredOptions = normalisedOptions.slice().filter((item) => {
399
- // filter out items that don't match `filter`
400
- if (typeof item === "object") {
401
- if (used_search_key) {
402
- return (
403
- typeof item[used_search_key] === "string" &&
404
- match(filter, item[used_search_key])
405
- );
406
- } else {
407
- for (var key in item) {
408
- return typeof item[key] === "string" && match(filter, item[key]);
409
- }
410
- }
411
- } else {
412
- return match(filter, item);
413
- }
414
- });
409
+ if (allow_fuzzy_match) {
410
+ filteredOptions = fuzzySearch(filter, [...normalisedOptions]);
411
+ } else {
412
+ filteredOptions = strictSearch(filter, [...normalisedOptions]);
413
+ }
414
+ }
415
+ }
416
+
417
+ function updateActiveOption() {
418
+ if (
419
+ (activeOption && searching && !filteredOptions.includes(activeOption)) ||
420
+ (!activeOption && searchText)
421
+ ) {
422
+ activeOption = filteredOptions[0];
423
+ } else {
424
+ if (allow_fuzzy_match) {
425
+ activeOption = filteredOptions.find((opts) =>
426
+ matchesValue(activeOption, opts)
427
+ );
428
+ }
415
429
  }
416
430
  }
417
431
 
@@ -467,6 +481,11 @@ Default value: `<span>{option[search_key] || option}</span>`
467
481
  modifiers: [sameWidthPopperModifier],
468
482
  });
469
483
 
484
+ if (allow_fuzzy_match && fuzzy) {
485
+ fuzzy.analyzeSubTerms = true;
486
+ fuzzy.analyzeSubTermDepth = 10;
487
+ }
488
+
470
489
  //normalize value for single versus multiselect
471
490
  if (value === null || typeof value == "undefined") {
472
491
  value = single ? null : [];
@@ -603,6 +622,7 @@ Default value: `<span>{option[search_key] || option}</span>`
603
622
  setOptionsVisible(true);
604
623
  }
605
624
  } else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
625
+ showOptions && event.preventDefault();
606
626
  const increment = event.key === `ArrowUp` ? -1 : 1;
607
627
  const newActiveIdx = filteredOptions.indexOf(activeOption) + increment;
608
628
 
@@ -666,16 +686,15 @@ Default value: `<span>{option[search_key] || option}</span>`
666
686
  if (_value === null || typeof _value == "undefined") {
667
687
  return false;
668
688
  }
669
- return (
670
- `${_value[used_value_key] || _value}` === `${_option[used_value_key]}`
671
- );
689
+ let value =
690
+ typeof _value === "object" && `${used_value_key}` in _value
691
+ ? _value[used_value_key]
692
+ : _value;
693
+ return `${value}` === `${_option[used_value_key]}`;
672
694
  };
673
695
 
674
696
  const match = (needle, haystack) => {
675
- let _hayStack = haystack.toLowerCase();
676
- return allow_fuzzy_match
677
- ? fuzzysearch(needle, _hayStack)
678
- : _hayStack.indexOf(needle) > -1;
697
+ return haystack.toLowerCase().indexOf(needle) > -1;
679
698
  };
680
699
 
681
700
  const normaliseArraysToObjects = (arr) => {
@@ -696,4 +715,48 @@ Default value: `<span>{option[search_key] || option}</span>`
696
715
  searching = false;
697
716
  });
698
717
  };
718
+
719
+ function fuzzySearch(filter, options) {
720
+ if (!filter) return options;
721
+ if (options.length) {
722
+ let OPTS = options.map((item) => {
723
+ let output = fuzzy(item[used_search_key], filter);
724
+ item = { ...output, original: item };
725
+ item.score =
726
+ !item.score || (item.score && item.score < output.score)
727
+ ? output.score
728
+ : item.score || 0;
729
+ return item;
730
+ });
731
+
732
+ let maxScore = Math.max(...OPTS.map((i) => i.score));
733
+ let calculatedLimit = maxScore - scoreThreshold;
734
+
735
+ OPTS = OPTS.filter(
736
+ (r) => r.score > (calculatedLimit > 0 ? calculatedLimit : 0)
737
+ ).map((o) => o.original);
738
+
739
+ return OPTS;
740
+ }
741
+ }
742
+
743
+ function strictSearch(filter, options) {
744
+ return options.filter((item) => {
745
+ // filter out items that don't match `filter`
746
+ if (typeof item === "object") {
747
+ if (used_search_key) {
748
+ return (
749
+ typeof item[used_search_key] === "string" &&
750
+ match(filter, item[used_search_key])
751
+ );
752
+ } else {
753
+ for (var key in item) {
754
+ return typeof item[key] === "string" && match(filter, item[key]);
755
+ }
756
+ }
757
+ } else {
758
+ return match(filter, item);
759
+ }
760
+ });
761
+ }
699
762
  </script>
package/index.js CHANGED
@@ -49,6 +49,7 @@ export { default as Transition } from "./transitions/Transition.svelte";
49
49
  export { default as SlidingPane } from "./sliding-panes/SlidingPane.svelte";
50
50
  export { default as SlidingPaneSet } from "./sliding-panes/SlidingPaneSet.svelte";
51
51
 
52
+ export { default as AutoComplete } from "./forms/AutoComplete.svelte";
52
53
  export { default as SearchableSelect } from "./forms/select/SearchableSelect.svelte";
53
54
  export { default as MultiSelect } from "./forms/select/MultiSelect.svelte";
54
55
  export { default as MaskedInput } from "./forms/MaskedInput.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kws3/ui",
3
- "version": "1.8.3",
3
+ "version": "1.9.1",
4
4
  "description": "UI components for use with Svelte v3 applications.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -25,9 +25,10 @@
25
25
  "dependencies": {
26
26
  "apexcharts": "3.33.2",
27
27
  "flatpickr": "^4.5.2",
28
+ "fuzzy.js": "^0.1.0",
28
29
  "svelte-portal": "^2.1.2",
29
30
  "text-mask-core": "^5.1.2",
30
31
  "tippy.js": "^6.3.1"
31
32
  },
32
- "gitHead": "b211043d95c9b0f3e4184624ccd84b0a99687e50"
33
+ "gitHead": "cc856b925257a0978959fa01b32b7bea6dc52c5c"
33
34
  }
@@ -0,0 +1,132 @@
1
+ $kws-theme-colors: $colors !default;
2
+ $kws-autocomplete-radius: $radius !default;
3
+ $kws-autocomplete-border-color: $input-border-color !default;
4
+ $kws-autocomplete-box-shadow: 0 0.5em 1em -0.125em rgb(10 10 10 / 10%),
5
+ 0 0px 0 1px rgb(10 10 10 / 2%) !default;
6
+ $kws-autocomplete-focus-border-color: $input-focus-border-color !default;
7
+ $kws-autocomplete-focus-box-shadow-size: $input-focus-box-shadow-size !default;
8
+ $kws-autocomplete-focus-box-shadow-color: $input-focus-box-shadow-color !default;
9
+ $kws-autocomplete-disabled-background-color: $input-disabled-background-color !default;
10
+ $kws-autocomplete-disabled-border-color: $input-disabled-border-color !default;
11
+ $kws-autocomplete-disabled-color: $input-disabled-color !default;
12
+ $kws-autocomplete-selecting-color: $primary-invert !default;
13
+ $kws-autocomplete-selecting-background: $primary !default;
14
+ $kws-autocomplete-text-matches-color: currentColor !default;
15
+ $kws-autocomplete-text-matches-background: transparent !default;
16
+ $kws-autocomplete-text-matches-font-weight: $weight-bold !default;
17
+
18
+ $__modal-z: 41 !default;
19
+ @if $modal-z {
20
+ $__modal-z: $modal-z;
21
+ }
22
+
23
+ $kws-autocomplete-options-z-index: $__modal-z + 1 !default;
24
+
25
+ .kws-autocomplete {
26
+ position: relative;
27
+ align-items: center;
28
+ display: flex;
29
+ cursor: text;
30
+ height: auto;
31
+ min-height: 2.5em;
32
+ padding-top: calc(0.4em - 1px);
33
+ padding-bottom: calc(0.4em - 1px);
34
+ &:focus-within {
35
+ border-color: $kws-autocomplete-focus-border-color;
36
+ box-shadow: $kws-autocomplete-focus-box-shadow-size
37
+ $kws-autocomplete-focus-box-shadow-color;
38
+ }
39
+ &.is-disabled {
40
+ background-color: $kws-autocomplete-disabled-background-color;
41
+ border-color: $kws-autocomplete-disabled-border-color;
42
+ color: $kws-autocomplete-disabled-color;
43
+ cursor: not-allowed;
44
+ }
45
+ &.is-readonly {
46
+ box-shadow: none;
47
+ }
48
+
49
+ input {
50
+ border: none !important;
51
+ outline: none !important;
52
+ background: none !important;
53
+ /* needed to hide red shadow around required inputs in some browsers */
54
+ box-shadow: none !important;
55
+ color: inherit;
56
+ flex: 1;
57
+ padding: 1pt;
58
+ height: auto;
59
+ min-height: 0;
60
+ }
61
+
62
+ ul.options {
63
+ list-style: none;
64
+ max-height: 50vh;
65
+ padding: 0;
66
+ cursor: pointer;
67
+ overflow: auto;
68
+ background: #fff;
69
+ border: 1px solid $kws-autocomplete-border-color;
70
+ box-shadow: $kws-autocomplete-box-shadow;
71
+ position: relative;
72
+ z-index: 4;
73
+ &[data-popper-placement="top"] {
74
+ border-radius: $kws-autocomplete-radius $kws-autocomplete-radius 0 0;
75
+ box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.4);
76
+ }
77
+ &.hidden {
78
+ display: none;
79
+ }
80
+
81
+ li {
82
+ padding: 0.3em 0.5em;
83
+ position: relative;
84
+ word-break: break-all;
85
+ &.active {
86
+ // keyboard focused item
87
+ color: $kws-autocomplete-selecting-color;
88
+ background: $kws-autocomplete-selecting-background;
89
+ }
90
+ span.h {
91
+ // highlight text matches
92
+ font-weight: $kws-autocomplete-text-matches-font-weight;
93
+ color: $kws-autocomplete-text-matches-color;
94
+ background: $kws-autocomplete-text-matches-background;
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ @each $name, $pair in $kws-theme-colors {
101
+ $color: nth($pair, 1);
102
+ $color-invert: nth($pair, 2);
103
+ $color-light: findLightColor($color);
104
+ $color-dark: findDarkColor($color);
105
+ .kws-autocomplete {
106
+ &.is-#{$name} {
107
+ border-color: $color;
108
+ &:focus-within {
109
+ box-shadow: $input-focus-box-shadow-size bulmaRgba($color, 0.25);
110
+ }
111
+ ul.options {
112
+ li {
113
+ &.selected {
114
+ color: $color-dark;
115
+ background: $color-light;
116
+ }
117
+ &.active {
118
+ color: $color-invert;
119
+ background: $color;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ #kws-overlay-root {
128
+ .kws-autocomplete {
129
+ position: absolute;
130
+ z-index: $kws-autocomplete-options-z-index;
131
+ }
132
+ }
@@ -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 {