@kws3/ui 1.7.4 → 1.8.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,18 @@
1
+ ## 1.8.2
2
+ - Usability fixes for `NumberInput`
3
+ - New `input_only`, `force_integer`, `style` and `class` props for `NumberInput`
4
+ - Forward `focus`, `blur` input events for `NumberInput`
5
+ - Use custom version of `fuzzysearch` for `SearchableSelect` and `MultiSelect`
6
+
7
+ ## 1.8.1
8
+ - New `ScrollableList` component
9
+
10
+ ## 1.8.0
11
+ - `Modal`, `CardModal` and `ActionSheet` components now play an outro transition instead of abruptly disappearing.
12
+ - Usability fixes for `SearchableSelect` and `MultiSelect`.
13
+ - `SearchableSelect` and `MultiSelect` now support loading options via an async function.
14
+ - `SearchableSelect` and `MultiSelect` now match results using a fuzzy `search_strategy`. This can be changed to old behaviour by specifying `search_strategy="strict"`.
15
+
1
16
  ## 1.7.4
2
17
  - Update ApexCharts to version 3.33.2
3
18
  - Added support for subscribing to chart events
@@ -28,7 +28,6 @@
28
28
  <p class="control">
29
29
  {#if _confirm}
30
30
  <button
31
- role="button"
32
31
  class="button is-success is-light is-shadowless is-{size} {button_class}"
33
32
  type="button"
34
33
  on:click|preventDefault|stopPropagation={cancel}>
@@ -38,7 +37,6 @@
38
37
  </p>
39
38
  <p class="control is-expanded">
40
39
  <button
41
- role="button"
42
40
  class="button is-{size} {_doing
43
41
  ? main_color + ' is-loading'
44
42
  : _error
@@ -20,54 +20,84 @@ This will be overridden if `min` is higher, or `max` is lower, Default: `0`
20
20
  @param {string} [plus_icon="plus"] - Name of the icon that is to be displayed in the plus button, Default: `"plus"`
21
21
  @param {''|'success'|'primary'|'warning'|'info'|'danger'|'dark'|'light'} [plus_icon_color="success"] - Color of the Plus Icon, Default: `"success"`
22
22
  @param {''|'success'|'primary'|'warning'|'info'|'danger'|'dark'|'light'} [plus_button_color=""] - Color of the Plus Button, Default: `""`
23
+ @param {boolean} [input_only=false] - Show input without controls, Default: `false`
24
+ @param {boolean} [force_integer=false] - Prevent decimal numbers such as `1.5`, Default: `false`
25
+ @param {string} [style=""] - Inline CSS for component, Default: `""`
26
+ @param {string} [class=""] - CSS classes for component, Default: `""`
23
27
 
24
28
  ### Events
25
29
  - `change` - Triggered when value changes
30
+ - `blur`
31
+ - `focus`
26
32
 
27
33
  -->
28
- <div class="field has-addons">
29
- <div class="control">
30
- <button
31
- role="button"
32
- type="button"
33
- class="button is-{size} is-{minus_button_color}"
34
- style="box-shadow:none;"
35
- on:click={count(-1)}
36
- disabled={disabled || value <= min}>
37
- <Icon
38
- icon={minus_icon}
39
- size="small"
40
- class="has-text-{minus_icon_color}" />
41
- </button>
34
+ {#if input_only}
35
+ <input
36
+ {style}
37
+ data-testid="input"
38
+ class="input has-text-centered {klass} is-{size} is-{value < min ||
39
+ value > max
40
+ ? 'danger'
41
+ : ''}"
42
+ type="number"
43
+ min
44
+ max
45
+ {step}
46
+ {disabled}
47
+ readonly={!typeable}
48
+ bind:value
49
+ on:blur={isBlurred}
50
+ on:blur
51
+ on:focus={isFocused}
52
+ on:focus />
53
+ {:else}
54
+ <div class="field has-addons {klass}" {style}>
55
+ <div class="control">
56
+ <button
57
+ type="button"
58
+ class="button is-{size} is-{minus_button_color}"
59
+ style="box-shadow:none;"
60
+ on:click={count(-1)}
61
+ disabled={disabled || value <= min}>
62
+ <Icon
63
+ icon={minus_icon}
64
+ size="small"
65
+ class="has-text-{minus_icon_color}" />
66
+ </button>
67
+ </div>
68
+ <div class="control is-{fullwidth ? 'expanded' : 'narrow'}">
69
+ <input
70
+ data-testid="input"
71
+ class="input has-text-centered is-{size} is-{value < min || value > max
72
+ ? 'danger'
73
+ : ''}"
74
+ type="number"
75
+ min
76
+ max
77
+ {step}
78
+ {disabled}
79
+ readonly={!typeable}
80
+ bind:value
81
+ on:blur={isBlurred}
82
+ on:blur
83
+ on:focus={isFocused}
84
+ on:focus />
85
+ </div>
86
+ <div class="control">
87
+ <button
88
+ type="button"
89
+ class="button is-{size} is-{plus_button_color}"
90
+ style="box-shadow:none;"
91
+ on:click|preventDefault={count(+1)}
92
+ disabled={disabled || value >= max}>
93
+ <Icon
94
+ icon={plus_icon}
95
+ size="small"
96
+ class="has-text-{plus_icon_color}" />
97
+ </button>
98
+ </div>
42
99
  </div>
43
- <div class="control is-{fullwidth ? 'expanded' : 'narrow'}">
44
- <input
45
- data-testid="input"
46
- class="input has-text-centered is-{size} is-{value < min || value > max
47
- ? 'danger'
48
- : ''}"
49
- type="number"
50
- min
51
- max
52
- step
53
- {disabled}
54
- readonly={!typeable}
55
- bind:value
56
- on:blur={isBlurred()}
57
- on:focus={isFocused()} />
58
- </div>
59
- <div class="control">
60
- <button
61
- role="button"
62
- type="button"
63
- class="button is-{size} is-{plus_button_color}"
64
- style="box-shadow:none;"
65
- on:click|preventDefault={count(+1)}
66
- disabled={disabled || value >= max}>
67
- <Icon icon={plus_icon} size="small" class="has-text-{plus_icon_color}" />
68
- </button>
69
- </div>
70
- </div>
100
+ {/if}
71
101
 
72
102
  <style>
73
103
  input[type="number"]::-webkit-inner-spin-button,
@@ -160,7 +190,25 @@ This will be overridden if `min` is higher, or `max` is lower, Default: `0`
160
190
  * Color of the Plus Button
161
191
  * @type {''|'success'|'primary'|'warning'|'info'|'danger'|'dark'|'light'}
162
192
  */
163
- plus_button_color = "";
193
+ plus_button_color = "",
194
+ /**
195
+ * Show input without controls
196
+ */
197
+ input_only = false,
198
+ /**
199
+ * Prevent decimal numbers such as `1.5`
200
+ */
201
+ force_integer = false,
202
+ /**
203
+ * Inline CSS for component
204
+ */
205
+ style = "";
206
+
207
+ /**
208
+ * CSS classes for component
209
+ */
210
+ let klass = "";
211
+ export { klass as class };
164
212
 
165
213
  let _has_focus = false,
166
214
  _old_value = null;
@@ -191,6 +239,8 @@ This will be overridden if `min` is higher, or `max` is lower, Default: `0`
191
239
 
192
240
  if (typeof value == "undefined" || value === null) value = min;
193
241
 
242
+ if (force_integer) value = Math.floor(Number(value));
243
+
194
244
  if (value < min) value = min;
195
245
  if (value > max) value = max;
196
246
 
@@ -16,6 +16,12 @@ Used to populate the list of options in the dropdown, Default: `[]`
16
16
  this property of each object will be searched, Default: `"name"`
17
17
  @param {string} [value_key="id"] - If `options` is an array of objects,
18
18
  this property of each object will be returned as the value, Default: `"id"`
19
+ @param {function|null} [search=null] - Async function to fetch options
20
+
21
+ Only send this prop if you want to fetch `options` asynchronously.
22
+ `options` prop will be ignored if this prop is set., Default: `null`
23
+ @param {'fuzzy'|'strict'} [search_strategy="fuzzy"] - Filtered options to be displayed strictly based on search text or perform a fuzzy match.
24
+ Fuzzy match will not work if `search` function is set, as the backend service is meant to do the matching., Default: `"fuzzy"`
19
25
  @param {''|'small'|'medium'|'large'} [size=""] - Size of the input, Default: `""`
20
26
  @param {''|'primary'|'success'|'warning'|'info'|'danger'|'dark'|'light'} [color=""] - Color of the input, Default: `""`
21
27
  @param {string} [style=""] - Inline CSS for input container, Default: `""`
@@ -24,6 +30,7 @@ this property of each object will be returned as the value, Default: `"id"`
24
30
  @param {string} [selected_icon="check"] - Icon used to mark selected items in dropdown list, Default: `"check"`
25
31
  @param {boolean} [summary_mode=false] - Shows only the number of items selected, instead of listing all the selected items in the input., Default: `false`
26
32
  @param {string} [no_options_msg="No matching options"] - Message to display when no matching options are found, Default: `"No matching options"`
33
+ @param {string} [async_search_prompt="Start typing to search..."] - Message to display in dropdown when async search can be performed, Default: `"Start typing to search..."`
27
34
  @param {string} [remove_btn_tip="Remove"] - Tooltip text for Remove Item button. This `string` will precede the selected Item Name in the tooltip., Default: `"Remove"`
28
35
  @param {string} [remove_all_tip="Remove all"] - Tooltip text for the Clear All button, Default: `"Remove all"`
29
36
  @param {HTMLElement|string} [dropdown_portal=undefined] - Where to render the dropdown list.
@@ -75,7 +82,6 @@ Default value: `<span>{option[search_key] || option}</span>`
75
82
  {#if !readonly && !disabled}
76
83
  <button
77
84
  on:click|self|stopPropagation={() => remove(tag)}
78
- role="button"
79
85
  type="button"
80
86
  class="delete is-small"
81
87
  data-tooltip="{remove_btn_tip} {tag[used_search_key]}" />
@@ -84,6 +90,7 @@ Default value: `<span>{option[search_key] || option}</span>`
84
90
  {/each}
85
91
  {/if}
86
92
  {/if}
93
+ {#if single}<span>{singleVisibleValue}</span>{/if}
87
94
  <input
88
95
  class="input is-{size}"
89
96
  bind:this={input}
@@ -98,9 +105,13 @@ Default value: `<span>{option[search_key] || option}</span>`
98
105
  on:blur={() => setOptionsVisible(false)}
99
106
  placeholder={_placeholder} />
100
107
  </ul>
101
- {#if !readonly && !disabled}
108
+ {#if search && options_loading}
109
+ <button
110
+ type="button"
111
+ style="border: none;"
112
+ class="button is-paddingless delete is-medium is-loading" />
113
+ {:else if !readonly && !disabled}
102
114
  <button
103
- role="button"
104
115
  type="button"
105
116
  class="remove-all delete is-small"
106
117
  data-tooltip={remove_all_tip}
@@ -133,7 +144,11 @@ Default value: `<span>{option[search_key] || option}</span>`
133
144
  {option}>{option[used_search_key] || option}</slot>
134
145
  </li>
135
146
  {:else}
136
- <li class="no-options">{no_options_msg}</li>
147
+ {#if !options_loading}
148
+ <li class="no-options">
149
+ {searchText ? no_options_msg : async_search_prompt}
150
+ </li>
151
+ {/if}
137
152
  {/each}
138
153
  </ul>
139
154
  </div>
@@ -142,8 +157,10 @@ Default value: `<span>{option[search_key] || option}</span>`
142
157
 
143
158
  <script>
144
159
  import { Icon, portal } from "@kws3/ui";
145
- import { createEventDispatcher, onMount } from "svelte";
160
+ import { debounce } from "@kws3/ui/utils";
161
+ import { createEventDispatcher, onMount, tick } from "svelte";
146
162
  import { createPopper } from "@popperjs/core";
163
+ import fuzzysearch from "@kws3/ui/utils/fuzzysearch";
147
164
 
148
165
  const sameWidthPopperModifier = {
149
166
  name: "sameWidth",
@@ -196,6 +213,22 @@ Default value: `<span>{option[search_key] || option}</span>`
196
213
  * this property of each object will be returned as the value
197
214
  */
198
215
  export let value_key = "id";
216
+ /**
217
+ * Async function to fetch options
218
+ *
219
+ * Only send this prop if you want to fetch `options` asynchronously.
220
+ * `options` prop will be ignored if this prop is set.
221
+ *
222
+ * @type {function|null}
223
+ */
224
+ export let search = null;
225
+
226
+ /**
227
+ * Filtered options to be displayed strictly based on search text or perform a fuzzy match.
228
+ * Fuzzy match will not work if `search` function is set, as the backend service is meant to do the matching.
229
+ * @type {'fuzzy'|'strict'}
230
+ */
231
+ export let search_strategy = "fuzzy";
199
232
  /**
200
233
  * Size of the input
201
234
  * @type {''|'small'|'medium'|'large'}
@@ -230,6 +263,10 @@ Default value: `<span>{option[search_key] || option}</span>`
230
263
  * Message to display when no matching options are found
231
264
  */
232
265
  export let no_options_msg = "No matching options";
266
+ /**
267
+ * Message to display in dropdown when async search can be performed
268
+ */
269
+ export let async_search_prompt = "Start typing to search...";
233
270
  /**
234
271
  * Tooltip text for Remove Item button. This `string` will precede the selected Item Name in the tooltip.
235
272
  * */
@@ -254,7 +291,8 @@ Default value: `<span>{option[search_key] || option}</span>`
254
291
  let klass = "";
255
292
  export { klass as class };
256
293
 
257
- if (!options || !options.length) console.error(`Missing options`);
294
+ if (!search && (!options || !options.length))
295
+ console.error(`Missing options`);
258
296
 
259
297
  if (max !== null && max < 0) {
260
298
  throw new TypeError(`max must be null or positive integer, got ${max}`);
@@ -281,9 +319,11 @@ Default value: `<span>{option[search_key] || option}</span>`
281
319
  showOptions = false,
282
320
  filteredOptions = [], //list of options filtered by search query
283
321
  normalisedOptions = [], //list of options normalised
284
- selectedOptions = []; //list of options that are selected
322
+ selectedOptions = [], //list of options that are selected
323
+ options_loading = false; //indictaes whether async search function is running
285
324
 
286
325
  $: single = max === 1;
326
+ $: asyncMode = search && typeof search === "function";
287
327
  $: hasValue = single
288
328
  ? value !== null && typeof value != "undefined"
289
329
  ? true
@@ -324,6 +364,13 @@ Default value: `<span>{option[search_key] || option}</span>`
324
364
  else return value.some((v) => matchesValue(v, option));
325
365
  };
326
366
 
367
+ $: singleVisibleValue =
368
+ !searching && single && hasValue && selectedOptions && selectedOptions[0]
369
+ ? selectedOptions[0][used_search_key]
370
+ : "";
371
+
372
+ $: allow_fuzzy_match = !search && search_strategy === "fuzzy";
373
+
327
374
  //convert arrays of strings into normalised arrays of objects
328
375
  function normaliseOptions() {
329
376
  let _items = options || [];
@@ -332,15 +379,7 @@ Default value: `<span>{option[search_key] || option}</span>`
332
379
  return;
333
380
  }
334
381
 
335
- normalisedOptions = _items.slice().map((item) => {
336
- if (typeof item === "object") {
337
- return item;
338
- }
339
- let __obj = {};
340
- __obj[used_search_key] = item;
341
- __obj[used_value_key] = item;
342
- return __obj;
343
- });
382
+ normalisedOptions = normaliseArraysToObjects(_items);
344
383
  }
345
384
 
346
385
  function updateFilteredOptions() {
@@ -353,29 +392,27 @@ Default value: `<span>{option[search_key] || option}</span>`
353
392
  } else {
354
393
  filter = searchText.toLowerCase();
355
394
  }
356
-
357
- filteredOptions = normalisedOptions.slice().filter((item) => {
358
- // filter out items that don't match `filter`
359
- if (typeof item === "object") {
360
- if (used_search_key) {
361
- if (
362
- typeof item[used_search_key] === "string" &&
363
- item[used_search_key].toLowerCase().indexOf(filter) > -1
364
- )
365
- return true;
366
- } else {
367
- for (var key in item) {
368
- if (
369
- typeof item[key] === "string" &&
370
- item[key].toLowerCase().indexOf(filter) > -1
371
- )
372
- return true;
395
+ if (asyncMode && searching) {
396
+ debouncedTriggerSearch(filter);
397
+ } 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
+ }
373
410
  }
411
+ } else {
412
+ return match(filter, item);
374
413
  }
375
- } else {
376
- return item.toLowerCase().indexOf(filter) > -1;
377
- }
378
- });
414
+ });
415
+ }
379
416
  }
380
417
 
381
418
  function fillSelectedOptions() {
@@ -383,9 +420,17 @@ Default value: `<span>{option[search_key] || option}</span>`
383
420
  selectedOptions = normalisedOptions.filter(
384
421
  (v) => `${v[used_value_key]}` === `${value}`
385
422
  );
386
- setSingleVisibleValue();
387
423
  } else {
388
- selectedOptions = normalisedOptions
424
+ let _normalisedOptions = asyncMode
425
+ ? [...selectedOptions, ...normalisedOptions].filter(
426
+ //de-dupe by `used_value_key` when in asyncMode
427
+ (value, idx, self) =>
428
+ idx ===
429
+ self.findIndex((v) => v[used_value_key] === value[used_value_key])
430
+ )
431
+ : normalisedOptions;
432
+
433
+ selectedOptions = _normalisedOptions
389
434
  .filter(
390
435
  (v) => value && value.some((vl) => `${v[used_value_key]}` === `${vl}`)
391
436
  )
@@ -398,6 +443,23 @@ Default value: `<span>{option[search_key] || option}</span>`
398
443
  POPPER && POPPER.update();
399
444
  }
400
445
 
446
+ function triggerSearch(filter) {
447
+ if (filter === "") {
448
+ //do not trigger async search if filter is empty
449
+ options = [];
450
+ searching = false;
451
+ return;
452
+ }
453
+ options_loading = true;
454
+ search(filter).then((_options) => {
455
+ options = _options;
456
+ searching = false;
457
+ options_loading = false;
458
+ });
459
+ }
460
+
461
+ const debouncedTriggerSearch = debounce(triggerSearch, 150, false);
462
+
401
463
  onMount(() => {
402
464
  POPPER = createPopper(el, dropdown, {
403
465
  strategy: "fixed",
@@ -406,10 +468,25 @@ Default value: `<span>{option[search_key] || option}</span>`
406
468
  });
407
469
 
408
470
  //normalize value for single versus multiselect
409
- if (value === null || typeof value == "undefined")
471
+ if (value === null || typeof value == "undefined") {
410
472
  value = single ? null : [];
473
+ }
411
474
 
412
- setSingleVisibleValue();
475
+ if (asyncMode) {
476
+ // initally on async mode options are empty
477
+ // so we need to fill selectedOptions with value if value is avaliable
478
+ options = value ? [...(single ? [value] : [...value])] : [];
479
+ searching = false;
480
+ tick().then(() => {
481
+ normaliseOptions();
482
+ value = normaliseArraysToObjects(options).map((v) => v[used_value_key]);
483
+ if (single && Array.isArray(value)) {
484
+ value = value[0];
485
+ }
486
+ fillSelectedOptions();
487
+ clearDropDownResults();
488
+ });
489
+ }
413
490
 
414
491
  return () => {
415
492
  POPPER.destroy();
@@ -424,24 +501,39 @@ Default value: `<span>{option[search_key] || option}</span>`
424
501
  let isAlreadySelected = isSelected(token);
425
502
 
426
503
  if (single) {
427
- if (isAlreadySelected) {
428
- setSingleVisibleValue();
429
- } else {
504
+ if (!isAlreadySelected) {
430
505
  value = token[used_value_key];
431
- input && input.blur();
432
- setOptionsVisible(false);
433
506
  fire("change", { token, type: `add` });
507
+ //clear dropdown results in asyncMode
508
+ if (asyncMode) {
509
+ clearDropDownResults();
510
+ }
434
511
  }
512
+ setOptionsVisible(false);
435
513
  }
436
514
 
437
515
  if (!isAlreadySelected && !single && (max === null || value.length < max)) {
438
- //attach to value array while filtering out invalid values
439
- value = [...value, token[used_value_key]].filter((v) => {
440
- return normalisedOptions.filter((nv) => nv[used_value_key] === v)
441
- .length;
442
- });
516
+ if (asyncMode) {
517
+ //Do not filter invalid options, as they are async and might not be invalid
518
+ //but ensure they are unique
519
+ value = [...value, token[used_value_key]].filter(
520
+ (v, i, a) => a.indexOf(v) === i
521
+ );
522
+ } else {
523
+ //attach to value array while filtering out invalid values
524
+ value = [...value, token[used_value_key]].filter((v) => {
525
+ return normalisedOptions.filter((nv) => nv[used_value_key] === v)
526
+ .length;
527
+ });
528
+ }
529
+
443
530
  searchText = ""; // reset search string on selection
444
531
 
532
+ //clear dropdown results in asyncMode
533
+ if (asyncMode) {
534
+ clearDropDownResults();
535
+ }
536
+
445
537
  if (value && value.length && value.length === max) {
446
538
  input && input.blur();
447
539
  setOptionsVisible(false);
@@ -460,6 +552,11 @@ Default value: `<span>{option[search_key] || option}</span>`
460
552
  value = value.filter
461
553
  ? value.filter((item) => !matchesValue(item, token))
462
554
  : value;
555
+
556
+ //clear dropdown results in asyncMode
557
+ if (asyncMode) {
558
+ clearDropDownResults();
559
+ }
463
560
  /**
464
561
  * Triggered when an item is removed from selected Items
465
562
  */
@@ -483,25 +580,16 @@ Default value: `<span>{option[search_key] || option}</span>`
483
580
  showOptions = show;
484
581
  if (show) {
485
582
  input && input.focus();
486
- }
487
- POPPER && POPPER.update();
488
- }
489
-
490
- function setSingleVisibleValue() {
491
- if (single && hasValue) {
492
- searchText =
493
- selectedOptions && selectedOptions[0]
494
- ? selectedOptions[0][used_search_key]
495
- : "";
583
+ } else {
584
+ searchText = "";
496
585
  searching = false;
497
586
  }
587
+ POPPER && POPPER.update();
498
588
  }
499
589
 
500
590
  function handleKeydown(event) {
501
591
  if (event.key === `Escape`) {
502
- if (!single) {
503
- searchText = "";
504
- }
592
+ searchText = "";
505
593
  } else {
506
594
  setOptionsVisible(true);
507
595
  }
@@ -510,9 +598,6 @@ Default value: `<span>{option[search_key] || option}</span>`
510
598
  event.preventDefault();
511
599
  if (activeOption) {
512
600
  handleOptionMouseDown(activeOption);
513
- if (!single) {
514
- searchText = "";
515
- }
516
601
  } else {
517
602
  // no active option means the options are closed in which case enter means open
518
603
  setOptionsVisible(true);
@@ -529,24 +614,31 @@ Default value: `<span>{option[search_key] || option}</span>`
529
614
  else activeOption = filteredOptions[newActiveIdx];
530
615
  }
531
616
  } else if (event.key === `Backspace`) {
532
- // only remove selected tags on backspace if there are any and no searchText characters remain
533
- if (searchText.length === 0) {
534
- if (single) {
535
- if (value) {
536
- value = null;
537
- }
538
- } else {
539
- if (value && value.length > 0) {
540
- value = value.slice(0, value.length - 1);
541
- }
617
+ if (single && hasValue) {
618
+ //for a single select
619
+ //if a value is already selected, backspace will clear the value
620
+ value = null;
621
+ searchText = "";
622
+ } else if (!single && searchText.length === 0) {
623
+ //for a multi select
624
+ // only remove selected tags on backspace if there are any and no searchText characters remain
625
+ if (value && value.length > 0) {
626
+ value = value.slice(0, value.length - 1);
542
627
  }
543
628
  } else {
544
- if (single) {
545
- searching = true;
546
- }
629
+ searching = true;
547
630
  }
548
631
  } else {
632
+ //for a single select
633
+ //if a value is already selected,
634
+ //ignore keys other than navigation, enter and backspace
549
635
  if (single) {
636
+ if (hasValue) {
637
+ event.preventDefault();
638
+ } else {
639
+ searching = true;
640
+ }
641
+ } else {
550
642
  searching = true;
551
643
  }
552
644
  }
@@ -565,6 +657,9 @@ Default value: `<span>{option[search_key] || option}</span>`
565
657
  fire("change", { token: value, type: `remove` });
566
658
  value = single ? null : [];
567
659
  searchText = "";
660
+ if (asyncMode) {
661
+ clearDropDownResults();
662
+ }
568
663
  };
569
664
 
570
665
  const matchesValue = (_value, _option) => {
@@ -575,4 +670,30 @@ Default value: `<span>{option[search_key] || option}</span>`
575
670
  `${_value[used_value_key] || _value}` === `${_option[used_value_key]}`
576
671
  );
577
672
  };
673
+
674
+ const match = (needle, haystack) => {
675
+ let _hayStack = haystack.toLowerCase();
676
+ return allow_fuzzy_match
677
+ ? fuzzysearch(needle, _hayStack)
678
+ : _hayStack.indexOf(needle) > -1;
679
+ };
680
+
681
+ const normaliseArraysToObjects = (arr) => {
682
+ return arr.slice().map((item) => {
683
+ if (typeof item === "object") {
684
+ return item;
685
+ }
686
+ let __obj = {};
687
+ __obj[used_search_key] = item;
688
+ __obj[used_value_key] = item;
689
+ return __obj;
690
+ });
691
+ };
692
+
693
+ const clearDropDownResults = () => {
694
+ tick().then(() => {
695
+ options = [];
696
+ searching = false;
697
+ });
698
+ };
578
699
  </script>
@@ -16,9 +16,16 @@ this property of each object will be returned as the value, Default: `"id"`
16
16
  @param {''|'primary'|'success'|'warning'|'info'|'danger'|'dark'|'light'} [color=""] - Color of the input, Default: `""`
17
17
  @param {string} [style=""] - Inline CSS for input container, Default: `""`
18
18
  @param {boolean} [readonly=false] - Marks component as read-only, Default: `false`
19
+ @param {function|null} [search=null] - Async function to fetch options
20
+
21
+ Only send this prop if you want to fetch `options` asynchronously.
22
+ `options` prop will be ignored if this prop is set., Default: `null`
23
+ @param {'fuzzy'|'strict'} [search_strategy="fuzzy"] - Filtered options to be displayed strictly based on search text or perform a fuzzy match.
24
+ Fuzzy match will not work if `search` function is set, as the backend service is meant to do the matching., Default: `"fuzzy"`
19
25
  @param {boolean} [disabled=false] - Disables the component, Default: `false`
20
26
  @param {string} [selected_icon="check"] - Icon used to mark selected items in dropdown list, Default: `"check"`
21
27
  @param {string} [no_options_msg="No matching options"] - Message to display when no matching options are found, Default: `"No matching options"`
28
+ @param {string} [async_search_prompt="Start typing to search..."] - Message to display in dropdown when async search can be performed, Default: `"Start typing to search..."`
22
29
  @param {string} [remove_all_tip="Clear Selection"] - Tooltip text for the Clear selection button, Default: `"Clear Selection"`
23
30
  @param {HTMLElement|string} [dropdown_portal=undefined] - Where to render the dropdown list.
24
31
  Can be a DOM element or a `string` with the CSS selector of the element.
@@ -44,13 +51,16 @@ Default value: `<span>{option[search_key] || option}</span>`
44
51
  {options}
45
52
  {search_key}
46
53
  {value_key}
54
+ {search_strategy}
47
55
  {size}
48
56
  {color}
49
57
  {style}
50
58
  {readonly}
51
59
  {disabled}
60
+ {search}
52
61
  {selected_icon}
53
62
  {remove_all_tip}
63
+ async_search_prompt={value ? "Backspace to clear" : async_search_prompt}
54
64
  {no_options_msg}
55
65
  {dropdown_portal}
56
66
  on:change={change}
@@ -118,6 +128,21 @@ Default value: `<span>{option[search_key] || option}</span>`
118
128
  * Marks component as read-only
119
129
  */
120
130
  export let readonly = false;
131
+ /**
132
+ * Async function to fetch options
133
+ *
134
+ * Only send this prop if you want to fetch `options` asynchronously.
135
+ * `options` prop will be ignored if this prop is set.
136
+ *
137
+ * @type {function|null}
138
+ */
139
+ export let search = null;
140
+ /**
141
+ * Filtered options to be displayed strictly based on search text or perform a fuzzy match.
142
+ * Fuzzy match will not work if `search` function is set, as the backend service is meant to do the matching.
143
+ * @type {'fuzzy'|'strict'}
144
+ */
145
+ export let search_strategy = "fuzzy";
121
146
  /**
122
147
  * Disables the component
123
148
  */
@@ -130,6 +155,10 @@ Default value: `<span>{option[search_key] || option}</span>`
130
155
  * Message to display when no matching options are found
131
156
  */
132
157
  export let no_options_msg = "No matching options";
158
+ /**
159
+ * Message to display in dropdown when async search can be performed
160
+ */
161
+ export let async_search_prompt = "Start typing to search...";
133
162
  /**
134
163
  * Tooltip text for the Clear selection button
135
164
  */
@@ -19,7 +19,9 @@ If `false` , the component won't have a close button, and will not close on clic
19
19
 
20
20
  -->
21
21
 
22
- <div class="modal {klass} {is_active ? 'is-active' : ''}" {style}>
22
+ <div
23
+ class="modal kws-action-sheet-outer {klass} {is_active ? 'is-active' : ''}"
24
+ {style}>
23
25
  {#if is_active}<div
24
26
  transition:fade={{ duration: transitionDuration }}
25
27
  class="modal-background"
@@ -34,7 +34,6 @@ Only visible when the
34
34
  transition:fade={{ duration: transitionDuration }}
35
35
  class="modal-background"
36
36
  on:click={clickOutside} />
37
-
38
37
  <div
39
38
  transition:scale={{
40
39
  duration: transitionDuration,
@@ -83,6 +82,17 @@ Only visible when the
83
82
  </div>
84
83
 
85
84
  <style lang="scss">
85
+ .modal {
86
+ display: flex;
87
+ visibility: hidden;
88
+ &.is-active {
89
+ visibility: visible;
90
+ }
91
+ .modal-card,
92
+ .modal-background {
93
+ transition: all 0.3s;
94
+ }
95
+ }
86
96
  @media screen and (min-width: 769px), print {
87
97
  .modal-card {
88
98
  min-width: 640px;
@@ -12,6 +12,8 @@ function createDialog(msg, props) {
12
12
 
13
13
  dialog.$on("_done", ({ detail }) => {
14
14
  fulfil(detail);
15
+ //Does not outro out because of
16
+ //https://github.com/sveltejs/svelte/issues/4056
15
17
  dialog.$destroy();
16
18
  });
17
19
  });
@@ -26,7 +26,6 @@ Only programmatic closing is possible, Default: `true`
26
26
  transition:fade={{ duration: transitionDuration }}
27
27
  class="modal-background"
28
28
  on:click={clickOutside} />
29
-
30
29
  <div
31
30
  transition:scale={{
32
31
  duration: transitionDuration,
@@ -47,9 +46,19 @@ Only programmatic closing is possible, Default: `true`
47
46
  </div>
48
47
 
49
48
  <style lang="scss">
50
- @media screen and (min-width: 769px), print {
49
+ .modal {
50
+ display: flex;
51
+ visibility: hidden;
52
+ &.is-active {
53
+ visibility: visible;
54
+ }
51
55
  .modal-content,
52
- .modal-card {
56
+ .modal-background {
57
+ transition: all 0.3s;
58
+ }
59
+ }
60
+ @media screen and (min-width: 769px), print {
61
+ .modal-content {
53
62
  min-width: 640px;
54
63
  &.is-medium {
55
64
  width: 70%;
@@ -0,0 +1,231 @@
1
+ <!--
2
+ @component
3
+
4
+
5
+ @param {array} [items=[]] - Array of items, Default: `[]`
6
+ @param {string} [height="100%"] - Height of the wrapper, CSS String, Default: `"100%"`
7
+ @param {number | null} [item_height=null] - Height of each list item. If not set, height will be calculated automatically based on each item's offsetHeight, Default: `null`
8
+ @param {number} [start=0] - First item index rendered inside viewport - readonly, Default: `0`
9
+ @param {number} [end=0] - Last item index rendered inside viewport - readonly, Default: `0`
10
+ @param {number} [end_threshold=10] - `end` event will be fired when the list reaches this many items before the end of the list., Default: `10`
11
+ @param {string} [style=""] - Inline CSS for scroller container, Default: `""`
12
+ @param {string} [class=""] - CSS classes for scroller container, Default: `""`
13
+
14
+ ### Events
15
+ - `end` - Fired when the list reaches `end_threshold` items before the end of the list.
16
+
17
+ ### Slots
18
+ - `<slot name="default" {item} {index} />` - Default slot for list view items
19
+ - `<slot name="loader" />` - Optional slot to display a loading graphic at the bottom of the list
20
+ while more items are loading
21
+
22
+ -->
23
+ {#if hasResizeObserver}
24
+ <div
25
+ bind:this={viewport}
26
+ class="kws-scrollable-list with-resize-observer {klass}"
27
+ on:scroll={handle_scroll}
28
+ style="height:{height};{style}"
29
+ use:resizeObserver
30
+ on:resize={resize}>
31
+ <div
32
+ bind:this={contents}
33
+ style="padding-top: {top}px; padding-bottom: {bottom}px;">
34
+ {#each visible as item (item.index)}
35
+ <div class="row">
36
+ <!--Default slot for list view items-->
37
+ <slot item={item.data} index={item.index} />
38
+ </div>
39
+ {/each}
40
+ <!--Optional slot to display a loading graphic at the bottom of the list
41
+ while more items are loading-->
42
+ <slot name="loader" />
43
+ </div>
44
+ </div>
45
+ {:else}
46
+ <div
47
+ bind:this={viewport}
48
+ class="kws-scrollable-list {klass}"
49
+ on:scroll={handle_scroll}
50
+ style="height:{height};{style}"
51
+ bind:offsetHeight={viewport_height}>
52
+ <div
53
+ bind:this={contents}
54
+ style="padding-top: {top}px; padding-bottom: {bottom}px;">
55
+ {#each visible as item (item.index)}
56
+ <div class="row">
57
+ <!--Default slot for list view items-->
58
+ <slot item={item.data} index={item.index} />
59
+ </div>
60
+ {/each}
61
+ <!--Optional slot to display a loading graphic at the bottom of the list
62
+ while more items are loading-->
63
+ <slot name="loader" />
64
+ </div>
65
+ </div>
66
+ {/if}
67
+
68
+ <style>
69
+ .kws-scrollable-list {
70
+ overflow: auto;
71
+ -webkit-overflow-scrolling: touch;
72
+ position: relative;
73
+ height: 100%;
74
+ }
75
+ </style>
76
+
77
+ <script>
78
+ import { onMount, tick } from "svelte";
79
+ import { createEventDispatcher } from "svelte";
80
+ import {
81
+ resizeObserver,
82
+ hasResizeObserver,
83
+ } from "@kws3/ui/utils/resizeObserver";
84
+
85
+ const fire = createEventDispatcher();
86
+ /**
87
+ * Array of items
88
+ */
89
+ export let items = [],
90
+ /**
91
+ * Height of the wrapper, CSS String
92
+ */
93
+ height = "100%",
94
+ /**
95
+ * Height of each list item. If not set, height will be calculated automatically based on each item's offsetHeight
96
+ * @type {number | null}
97
+ */
98
+ item_height = null,
99
+ /**
100
+ * First item index rendered inside viewport - readonly
101
+ */
102
+ start = 0,
103
+ /**
104
+ * Last item index rendered inside viewport - readonly
105
+ */
106
+ end = 0,
107
+ /**
108
+ * `end` event will be fired when the list reaches this many items before the end of the list.
109
+ */
110
+ end_threshold = 10,
111
+ /**
112
+ * Inline CSS for scroller container
113
+ */
114
+ style = "";
115
+
116
+ /**
117
+ * CSS classes for scroller container
118
+ */
119
+ let klass = "";
120
+ export { klass as class };
121
+
122
+ // local state
123
+ let height_map = [],
124
+ rows,
125
+ viewport,
126
+ contents,
127
+ viewport_height = 0,
128
+ visible,
129
+ mounted,
130
+ top = 0,
131
+ bottom = 0,
132
+ average_height,
133
+ items_count = 0;
134
+
135
+ $: visible = items.slice(start, end).map((data, i) => {
136
+ return { index: i + start, data };
137
+ });
138
+
139
+ // whenever `items` changes, invalidate the current heightmap
140
+ $: items, viewport_height, item_height, mounted, refresh();
141
+
142
+ async function refresh() {
143
+ if (!mounted) return;
144
+ const scrollTop = viewport.scrollTop;
145
+ await tick(); // wait until the DOM is up to date
146
+ let content_height = top - scrollTop;
147
+ let i = start;
148
+ while (content_height < viewport_height && i < items.length) {
149
+ let row = rows[i - start];
150
+ if (!row) {
151
+ end = i + 1;
152
+ await tick(); // render the newly visible row
153
+ row = rows[i - start];
154
+ }
155
+ const row_height = (height_map[i] =
156
+ item_height || (row ? row.offsetHeight : 0));
157
+ content_height += row_height;
158
+ i += 1;
159
+ }
160
+ end = i;
161
+ const remaining = items.length - end;
162
+ average_height = (top + content_height) / end;
163
+ bottom = remaining * average_height;
164
+ height_map.length = items.length;
165
+ }
166
+
167
+ async function handle_scroll() {
168
+ const scrollTop = viewport.scrollTop;
169
+ const old_start = start;
170
+ for (let v = 0; v < rows.length; v += 1) {
171
+ height_map[start + v] = item_height || rows[v].offsetHeight;
172
+ }
173
+ let i = 0;
174
+ let y = 0;
175
+ while (i < items.length) {
176
+ const row_height = height_map[i] || average_height;
177
+ if (y + row_height > scrollTop) {
178
+ start = i;
179
+ top = y;
180
+ break;
181
+ }
182
+ y += row_height;
183
+ i += 1;
184
+ }
185
+ while (i < items.length) {
186
+ y += height_map[i] || average_height;
187
+ i += 1;
188
+ if (y > scrollTop + viewport_height) break;
189
+ }
190
+ end = i;
191
+ const remaining = items.length - end;
192
+ average_height = y / end;
193
+ while (i < items.length) height_map[i++] = average_height;
194
+ bottom = remaining * average_height;
195
+ // prevent jumping if we scrolled up into unknown territory
196
+ if (start < old_start) {
197
+ await tick();
198
+ let expected_height = 0;
199
+ let actual_height = 0;
200
+ for (let i = start; i < old_start; i += 1) {
201
+ if (rows[i - start]) {
202
+ expected_height += height_map[i];
203
+ actual_height += item_height || rows[i - start].offsetHeight;
204
+ }
205
+ }
206
+ const d = actual_height - expected_height;
207
+ viewport.scrollTo(0, scrollTop + d);
208
+ }
209
+ // fire on:end event if we scrolled past the end of the list
210
+ if (end > items.length - end_threshold) {
211
+ if (items_count !== items.length) {
212
+ items_count = items.length;
213
+ await tick();
214
+ /**
215
+ * Fired when the list reaches `end_threshold` items before the end of the list.
216
+ */
217
+ fire("end", { start, end });
218
+ }
219
+ }
220
+ }
221
+
222
+ const resize = () => {
223
+ viewport_height = viewport.offsetHeight;
224
+ };
225
+
226
+ // trigger initial refresh
227
+ onMount(() => {
228
+ rows = contents.getElementsByClassName("row");
229
+ mounted = true;
230
+ });
231
+ </script>
package/index.js CHANGED
@@ -17,6 +17,7 @@ export { default as TimelineItem } from "./helpers/Timeline/TimelineItem.svelte"
17
17
  export { default as TimelineHeader } from "./helpers/Timeline/TimelineHeader.svelte";
18
18
  export { default as Nl2br } from "./helpers/Nl2br.svelte";
19
19
  export { default as ClipboardCopier } from "./helpers/ClipboardCopier.svelte";
20
+ export { default as ScrollableList } from "./helpers/ScrollableList.svelte";
20
21
  export { alert, confirm, prompt, default as Dialog } from "./helpers/Dialog";
21
22
  export {
22
23
  Notifications,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kws3/ui",
3
- "version": "1.7.4",
3
+ "version": "1.8.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": "fefd40197784db6665d8f74c358931cc5e8becaf"
32
+ "gitHead": "9fcbe3d34101490398b7bf571334e84e9ee04aee"
33
33
  }
@@ -25,6 +25,8 @@ This will work only when `track_height` is set to `true`
25
25
  : ''} {h_center ? 'h-centered' : ''} {active ? 'is-active' : ''} {klass}"
26
26
  {style}>
27
27
  <div
28
+ use:resizeObserver
29
+ on:resize={debouncedFireSizeChange}
28
30
  bind:this={slideInner}
29
31
  class="sliding-pane-inner {v_center ? 'v-centered' : ''} {h_center
30
32
  ? 'h-centered'
@@ -52,6 +54,10 @@ This will work only when `track_height` is set to `true`
52
54
  <script>
53
55
  import { onMount, createEventDispatcher } from "svelte";
54
56
  import { debounce } from "@kws3/ui/utils";
57
+ import {
58
+ resizeObserver,
59
+ hasResizeObserver,
60
+ } from "@kws3/ui/utils/resizeObserver";
55
61
 
56
62
  const fire = createEventDispatcher();
57
63
 
@@ -76,8 +82,7 @@ This will work only when `track_height` is set to `true`
76
82
  */
77
83
  track_height = true;
78
84
 
79
- const hasResizeObserver = typeof window.ResizeObserver != "undefined";
80
- let _height, slideInner, Observer;
85
+ let _height, slideInner;
81
86
 
82
87
  /**
83
88
  * CSS classes for the panel
@@ -91,18 +96,22 @@ This will work only when `track_height` is set to `true`
91
96
  }
92
97
  }
93
98
 
99
+ const max_retries_for_render = 10;
100
+ let try_count = 0;
94
101
  function pollForRender() {
95
102
  if (slideInner && typeof slideInner != "undefined") {
96
103
  init();
97
104
  } else {
98
105
  setTimeout(() => {
99
- pollForRender();
106
+ try_count++;
107
+ if (try_count < max_retries_for_render) {
108
+ pollForRender();
109
+ }
100
110
  }, 50);
101
111
  }
102
112
  }
103
113
 
104
114
  function init() {
105
- setupResizeObserver();
106
115
  fireSizeChange();
107
116
  }
108
117
 
@@ -129,23 +138,7 @@ This will work only when `track_height` is set to `true`
129
138
 
130
139
  const debouncedFireSizeChange = debounce(fireSizeChange, 150, false);
131
140
 
132
- const setupResizeObserver = () => {
133
- if (hasResizeObserver) {
134
- if (!slideInner || typeof slideInner == "undefined") {
135
- pollForRender();
136
- } else {
137
- Observer = new ResizeObserver(() => {
138
- debouncedFireSizeChange();
139
- });
140
- Observer.observe(slideInner);
141
- }
142
- }
143
- };
144
-
145
141
  onMount(() => {
146
142
  pollForRender();
147
- return () => {
148
- Observer && Observer.disconnect();
149
- };
150
143
  });
151
144
  </script>
@@ -2,6 +2,16 @@ $kws-actionsheet-background: $background !default;
2
2
  $kws-actionsheet-box-shadow: $card-shadow !default;
3
3
  $kws-actionsheet-box-radius: $radius !default;
4
4
 
5
+ .kws-action-sheet-outer {
6
+ display: flex;
7
+ visibility: hidden;
8
+ &.is-active {
9
+ visibility: visible;
10
+ }
11
+ .modal-background {
12
+ transition: all 0.3s;
13
+ }
14
+ }
5
15
  .kws-action-sheet {
6
16
  border-radius: $kws-actionsheet-box-radius $kws-actionsheet-box-radius 0 0;
7
17
  box-shadow: $kws-actionsheet-box-shadow;
@@ -0,0 +1,20 @@
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;
15
+ }
16
+ }
17
+ return false;
18
+ }
19
+ return true;
20
+ }
@@ -0,0 +1,24 @@
1
+ export const hasResizeObserver = typeof window.ResizeObserver != "undefined";
2
+
3
+ /**
4
+ * Usage: `<div use:resizeObserver on:resize={resizeHandler}>`
5
+ * @param {HTMLElement} node
6
+ * @returns {Object}
7
+ */
8
+ export function resizeObserver(node) {
9
+ let ro;
10
+ if (hasResizeObserver) {
11
+ ro = new ResizeObserver(() => {
12
+ const e = new CustomEvent("resize", { bubbles: false });
13
+ node.dispatchEvent(e);
14
+ });
15
+
16
+ ro.observe(node);
17
+ }
18
+
19
+ return {
20
+ destroy() {
21
+ hasResizeObserver && ro.disconnect();
22
+ },
23
+ };
24
+ }