@kws3/ui 1.1.0 → 1.2.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.
@@ -0,0 +1,581 @@
1
+ <!--
2
+ @component
3
+
4
+
5
+ @param {array} [value=[]] - Value of the Input
6
+
7
+ This property can be bound to, to fetch the current value, Default: `[]`
8
+ @param {object} [max=null] - Maximum number of selectable items from dropdown list.
9
+
10
+ Accepts a `null` value for unlimited selected items.
11
+ Or a number value, Default: `null`
12
+ @param {string} [placeholder="Please select..."] - Placeholder text for the input, Default: `"Please select..."`
13
+ @param {array} [options=[]] - Array of strings, or objects.
14
+ Used to populate the list of options in the dropdown, Default: `[]`
15
+ @param {string} [search_key="name"] - If `options` is an array of objects,
16
+ this property of each object will be searched, Default: `"name"`
17
+ @param {string} [value_key="id"] - If `options` is an array of objects,
18
+ this property of each object will be returned as the value, Default: `"id"`
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 {string} [selected_icon="check"] - Icon used to mark selected items in dropdown list, Default: `"check"`
25
+ @param {boolean} [summary_mode=false] - When activated, it will show the number of selected items.
26
+
27
+ Instead of listing all the selected items inside the input., Default: `false`
28
+ @param {string} [no_options_msg="No matching options"] - Message to display when no matching options are found, Default: `"No matching options"`
29
+ @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"`
30
+ @param {string} [remove_all_tip="Remove all"] - Tooltip text for the Clear All button, Default: `"Remove all"`
31
+ @param {string} [class=""] - CSS classes for input container, Default: `""`
32
+
33
+ ### Events
34
+ - `change` - Triggered when the value changes
35
+ - `add` - Triggered when an item is added from dropdown list
36
+ - `remove` - Triggered when an item is removed from selected Items
37
+ - `blur` - Triggered when the input loses focus
38
+
39
+ ### Slots
40
+ - `<slot name="default" {search_key} {option} />` - Slot containing text for each selectable item
41
+
42
+ Default value: `<span>{option[search_key] || option}</span>`
43
+
44
+ -->
45
+ <div
46
+ bind:this={el}
47
+ class="
48
+ searchableselect input
49
+ {disabled ? 'is-disabled' : ''}
50
+ {readonly ? 'is-readonly' : ''}
51
+ is-{size} is-{color} {klass}
52
+ "
53
+ class:readonly
54
+ class:single
55
+ {style}
56
+ on:click|stopPropagation={() => setOptionsVisible(true)}>
57
+ <ul class="tokens tags {summary_mode ? 'has-addons' : ''}">
58
+ {#if !single && selectedOptions && selectedOptions.length > 0}
59
+ {#if summary_mode}
60
+ <li class="tag summary-count is-{size} is-{color || 'primary'}">
61
+ {selectedOptions.length}
62
+ </li>
63
+ <li
64
+ class="tag is-{size} summary-text is-{color || 'primary'} is-light">
65
+ Item{selectedOptions.length == 1 ? "" : "s"} selected
66
+ </li>
67
+ {:else}
68
+ {#each selectedOptions as tag}
69
+ <li
70
+ class="tag is-{size} is-{color || 'primary'} is-light"
71
+ on:click|self|stopPropagation={() => setOptionsVisible(true)}>
72
+ {tag[used_search_key]}
73
+ {#if !readonly && !disabled}
74
+ <button
75
+ on:click|self|stopPropagation={() => remove(tag)}
76
+ type="button"
77
+ class="delete is-small"
78
+ data-tooltip="{remove_btn_tip} {tag[used_search_key]}" />
79
+ {/if}
80
+ </li>
81
+ {/each}
82
+ {/if}
83
+ {/if}
84
+ <input
85
+ class="input is-{size}"
86
+ bind:this={input}
87
+ autocomplete="off"
88
+ {disabled}
89
+ {readonly}
90
+ bind:value={searchText}
91
+ on:click|self|stopPropagation={() => setOptionsVisible(true)}
92
+ on:keydown={handleKeydown}
93
+ on:focus={() => setOptionsVisible(true)}
94
+ on:blur={blurEvent}
95
+ on:blur={() => setOptionsVisible(false)}
96
+ placeholder={_placeholder} />
97
+ </ul>
98
+ {#if !readonly && !disabled}
99
+ <button
100
+ type="button"
101
+ class="remove-all delete is-small"
102
+ data-tooltip={remove_all_tip}
103
+ on:click|stopPropagation={removeAll}
104
+ style={shouldShowClearAll ? "" : "display: none;"} />
105
+ {/if}
106
+
107
+ <ul
108
+ bind:this={dropdown}
109
+ class="options {single ? 'is-single' : 'is-multi'}"
110
+ class:hidden={!showOptions}>
111
+ {#each filteredOptions as option}
112
+ <li
113
+ on:mousedown|preventDefault|stopPropagation={() =>
114
+ handleOptionMouseDown(option)}
115
+ on:mouseenter|preventDefault|stopPropagation={() => {
116
+ activeOption = option;
117
+ }}
118
+ class:selected={isSelected(option)}
119
+ class:active={activeOption === option}>
120
+ <span class="kws-selected-icon"
121
+ ><Icon icon={selected_icon} size="small" /></span
122
+ ><!--
123
+ Slot containing text for each selectable item
124
+
125
+ Default value: `<span>{option[search_key] || option}</span>`
126
+ --><slot
127
+ search_key={used_search_key}
128
+ {option}>{option[used_search_key] || option}</slot>
129
+ </li>
130
+ {:else}
131
+ <li class="no-options">{no_options_msg}</li>
132
+ {/each}
133
+ </ul>
134
+ </div>
135
+
136
+ <script>
137
+ import { Icon } from "@kws3/ui";
138
+ import { createEventDispatcher, onMount } from "svelte";
139
+ import { createPopper } from "@popperjs/core";
140
+
141
+ const sameWidthPopperModifier = {
142
+ name: "sameWidth",
143
+ enabled: true,
144
+ phase: "beforeWrite",
145
+ requires: ["computeStyles"],
146
+ fn: ({ state }) => {
147
+ state.styles.popper.width = `${Math.max(
148
+ 200,
149
+ state.rects.reference.width
150
+ )}px`;
151
+ },
152
+ effect: ({ state }) => {
153
+ state.elements.popper.style.width = `${state.elements.reference.offsetWidth}px`;
154
+ },
155
+ };
156
+
157
+ /**
158
+ * Value of the Input
159
+ *
160
+ * This property can be bound to, to fetch the current value
161
+ */
162
+ export let value = [];
163
+ /**
164
+ * Maximum number of selectable items from dropdown list.
165
+ *
166
+ * Accepts a `null` value for unlimited selected items.
167
+ * Or a number value
168
+ */
169
+ export let max = null;
170
+ /**
171
+ * Placeholder text for the input
172
+ */
173
+ export let placeholder = "Please select...";
174
+ /**
175
+ * Array of strings, or objects.
176
+ * Used to populate the list of options in the dropdown
177
+ */
178
+ export let options = [];
179
+
180
+ /**
181
+ * If `options` is an array of objects,
182
+ * this property of each object will be searched
183
+ */
184
+ export let search_key = "name";
185
+ /**
186
+ * If `options` is an array of objects,
187
+ * this property of each object will be returned as the value
188
+ */
189
+ export let value_key = "id";
190
+ /**
191
+ * Size of the input
192
+ * @type {''|'small'|'medium'|'large'}
193
+ */
194
+ export let size = "";
195
+ /**
196
+ * Color of the input
197
+ * @type {''|'primary'|'success'|'warning'|'info'|'danger'|'dark'|'light'}
198
+ */
199
+ export let color = "";
200
+ /**
201
+ * Inline CSS for input container
202
+ */
203
+ export let style = "";
204
+ /**
205
+ * Marks component as read-only
206
+ */
207
+ export let readonly = false;
208
+ /**
209
+ * Disables the component
210
+ */
211
+ export let disabled = false;
212
+ /**
213
+ * Icon used to mark selected items in dropdown list
214
+ */
215
+ export let selected_icon = "check";
216
+ /**
217
+ * When activated, it will show the number of selected items.
218
+ *
219
+ * Instead of listing all the selected items inside the input.
220
+ */
221
+ export let summary_mode = false;
222
+ /**
223
+ * Message to display when no matching options are found
224
+ */
225
+ export let no_options_msg = "No matching options";
226
+ /**
227
+ * Tooltip text for Remove Item button. This `string` will precede the selected Item Name in the tooltip.
228
+ * */
229
+ export let remove_btn_tip = "Remove";
230
+ /**
231
+ * Tooltip text for the Clear All button
232
+ */
233
+ export let remove_all_tip = "Remove all";
234
+
235
+ /**
236
+ * CSS classes for input container
237
+ */
238
+ let klass = "";
239
+ export { klass as class };
240
+
241
+ if (!options || !options.length) console.error(`Missing options`);
242
+
243
+ if (max !== null && max < 0) {
244
+ throw new TypeError(`max must be null or positive integer, got ${max}`);
245
+ }
246
+
247
+ //ensure we have a root container for all our hoisitng related stuff
248
+ let rootContainerId = "kws-overlay-root";
249
+ let rootContainer = document.getElementById(rootContainerId);
250
+ if (!rootContainer) {
251
+ rootContainer = document.createElement("div");
252
+ rootContainer.id = rootContainerId;
253
+ document.body.appendChild(rootContainer);
254
+ }
255
+
256
+ //this is the container that will hold the dropdown
257
+ let container;
258
+
259
+ const fire = createEventDispatcher();
260
+
261
+ let el, //whole wrapping element
262
+ dropdown, //dropdown ul
263
+ input, //the textbox to type in
264
+ POPPER,
265
+ activeOption = "",
266
+ searchText = "",
267
+ searching = false,
268
+ showOptions = false,
269
+ filteredOptions = [], //list of options filtered by search query
270
+ normalisedOptions = [], //list of options normalised
271
+ selectedOptions = []; //list of options that are selected
272
+
273
+ $: single = max === 1;
274
+ $: hasValue = single
275
+ ? value !== null && typeof value != "undefined"
276
+ ? true
277
+ : false
278
+ : value && value.length
279
+ ? true
280
+ : false;
281
+ $: _placeholder = hasValue ? "" : placeholder;
282
+
283
+ //ensure search_key and value_key are no empty strings
284
+ $: used_search_key = search_key && search_key != "" ? search_key : "name";
285
+ $: used_value_key = value_key && value_key != "" ? value_key : "id";
286
+
287
+ $: shouldShowClearAll = hasValue;
288
+
289
+ $: options, normaliseOptions();
290
+ $: normalisedOptions,
291
+ searchText,
292
+ searching,
293
+ used_search_key,
294
+ used_value_key,
295
+ updateFilteredOptions();
296
+
297
+ $: value, single, fillSelectedOptions();
298
+
299
+ $: if (
300
+ (activeOption && !filteredOptions.includes(activeOption)) ||
301
+ (!activeOption && searchText)
302
+ )
303
+ activeOption = filteredOptions[0];
304
+
305
+ //TODO: optimise isSelected function
306
+ $: isSelected = (option) => {
307
+ if (single) return matchesValue(value, option);
308
+ if (!(value && value.length > 0) || value == "") return false;
309
+ // nothing is selected if `value` is the empty array or string
310
+ else return value.some((v) => matchesValue(v, option));
311
+ };
312
+
313
+ //convert arrays of strings into normalised arrays of objects
314
+ function normaliseOptions() {
315
+ let _items = options || [];
316
+ if (!_items || !(_items instanceof Array)) {
317
+ normalisedOptions = [];
318
+ return;
319
+ }
320
+
321
+ normalisedOptions = _items.slice().map((item) => {
322
+ if (typeof item === "object") {
323
+ return item;
324
+ }
325
+ let __obj = {};
326
+ __obj[used_search_key] = item;
327
+ __obj[used_value_key] = item;
328
+ return __obj;
329
+ });
330
+ }
331
+
332
+ function updateFilteredOptions() {
333
+ let filter;
334
+
335
+ //when in single mode, searchText contains the selected value
336
+ //so we need to check if we are actually searching
337
+ if (single && !searching) {
338
+ filter = "";
339
+ } else {
340
+ filter = searchText.toLowerCase();
341
+ }
342
+
343
+ filteredOptions = normalisedOptions.slice().filter((item) => {
344
+ // filter out items that don't match `filter`
345
+ if (typeof item === "object") {
346
+ if (used_search_key) {
347
+ if (
348
+ typeof item[used_search_key] === "string" &&
349
+ item[used_search_key].toLowerCase().indexOf(filter) > -1
350
+ )
351
+ return true;
352
+ } else {
353
+ for (var key in item) {
354
+ if (
355
+ typeof item[key] === "string" &&
356
+ item[key].toLowerCase().indexOf(filter) > -1
357
+ )
358
+ return true;
359
+ }
360
+ }
361
+ } else {
362
+ return item.toLowerCase().indexOf(filter) > -1;
363
+ }
364
+ });
365
+ }
366
+
367
+ function fillSelectedOptions() {
368
+ if (single) {
369
+ selectedOptions = normalisedOptions.filter(
370
+ (v) => `${v[used_value_key]}` == `${value}`
371
+ );
372
+ setSingleVisibleValue();
373
+ } else {
374
+ selectedOptions = normalisedOptions
375
+ .filter(
376
+ (v) => value && value.some((vl) => `${v[used_value_key]}` == `${vl}`)
377
+ )
378
+ .sort(
379
+ (a, b) =>
380
+ value.indexOf(a[used_value_key]) - value.indexOf(b[used_value_key])
381
+ );
382
+ }
383
+
384
+ POPPER && POPPER.update();
385
+ }
386
+
387
+ /**
388
+ * Moves dropdown to rootContainer, so that
389
+ * overflows etc do not mess with it
390
+ */
391
+ function hoistDropdown() {
392
+ if (!container) {
393
+ container = document.createElement("div");
394
+ container.className = "searchableselect";
395
+ rootContainer.appendChild(container);
396
+ container.appendChild(dropdown);
397
+ }
398
+ }
399
+
400
+ onMount(() => {
401
+ hoistDropdown();
402
+
403
+ POPPER = createPopper(el, dropdown, {
404
+ strategy: "fixed",
405
+ placement: "bottom-start",
406
+ modifiers: [sameWidthPopperModifier],
407
+ });
408
+
409
+ //normalize value for single versus multiselect
410
+ if (value === null || typeof value == "undefined")
411
+ value = single ? null : [];
412
+
413
+ setSingleVisibleValue();
414
+
415
+ return () => {
416
+ POPPER.destroy();
417
+ //remove hoisted items
418
+ container &&
419
+ container.parentNode &&
420
+ container.parentNode.removeChild(container);
421
+ };
422
+ });
423
+
424
+ function add(token) {
425
+ if (readonly || disabled) {
426
+ return;
427
+ }
428
+
429
+ let isAlreadySelected = isSelected(token);
430
+
431
+ if (single) {
432
+ if (isAlreadySelected) {
433
+ setSingleVisibleValue();
434
+ } else {
435
+ value = token[used_value_key];
436
+ input && input.blur();
437
+ setOptionsVisible(false);
438
+ fire("change", { token, type: `add` });
439
+ }
440
+ }
441
+
442
+ if (!isAlreadySelected && !single && (max === null || value.length < max)) {
443
+ //attach to value array while filtering out invalid values
444
+ value = [...value, token[used_value_key]].filter((v) => {
445
+ return normalisedOptions.filter((nv) => nv[used_value_key] == v).length;
446
+ });
447
+ searchText = ""; // reset search string on selection
448
+
449
+ if (value && value.length && value.length === max) {
450
+ input && input.blur();
451
+ setOptionsVisible(false);
452
+ }
453
+ /**
454
+ * Triggered when an item is added from dropdown list
455
+ */
456
+ fire("add", { token });
457
+
458
+ fire("change", { token, type: `add` });
459
+ }
460
+ }
461
+
462
+ function remove(token) {
463
+ if (readonly || disabled || single) return;
464
+ value = value.filter
465
+ ? value.filter((item) => !matchesValue(item, token))
466
+ : value;
467
+ /**
468
+ * Triggered when an item is removed from selected Items
469
+ */
470
+ fire("remove", { token });
471
+ /**
472
+ * Triggered when the value changes
473
+ */
474
+ fire("change", { token, type: `remove` });
475
+ }
476
+
477
+ function blurEvent() {
478
+ /**
479
+ * Triggered when the input loses focus
480
+ */
481
+ fire("blur");
482
+ }
483
+
484
+ function setOptionsVisible(show) {
485
+ // nothing to do if visibility is already as intended
486
+ if (readonly || disabled || show === showOptions) return;
487
+ showOptions = show;
488
+ if (show) {
489
+ input && input.focus();
490
+ }
491
+ POPPER && POPPER.update();
492
+ }
493
+
494
+ function setSingleVisibleValue() {
495
+ if (single && hasValue) {
496
+ searchText =
497
+ selectedOptions && selectedOptions[0]
498
+ ? selectedOptions[0][used_search_key]
499
+ : "";
500
+ searching = false;
501
+ }
502
+ }
503
+
504
+ function handleKeydown(event) {
505
+ if (event.key === `Escape`) {
506
+ if (!single) {
507
+ searchText = "";
508
+ }
509
+ } else {
510
+ setOptionsVisible(true);
511
+ }
512
+
513
+ if (event.key === `Enter`) {
514
+ if (activeOption) {
515
+ handleOptionMouseDown(activeOption);
516
+ if (!single) {
517
+ searchText = "";
518
+ }
519
+ } else {
520
+ // no active option means the options are closed in which case enter means open
521
+ setOptionsVisible(true);
522
+ }
523
+ } else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
524
+ const increment = event.key === `ArrowUp` ? -1 : 1;
525
+ const newActiveIdx = filteredOptions.indexOf(activeOption) + increment;
526
+
527
+ if (newActiveIdx < 0) {
528
+ activeOption = filteredOptions[filteredOptions.length - 1];
529
+ } else {
530
+ if (newActiveIdx === filteredOptions.length)
531
+ activeOption = filteredOptions[0];
532
+ else activeOption = filteredOptions[newActiveIdx];
533
+ }
534
+ } else if (event.key === `Backspace`) {
535
+ // only remove selected tags on backspace if there are any and no searchText characters remain
536
+ if (searchText.length === 0) {
537
+ if (single) {
538
+ if (value) {
539
+ value = null;
540
+ }
541
+ } else {
542
+ if (value && value.length > 0) {
543
+ value = value.slice(0, value.length - 1);
544
+ }
545
+ }
546
+ } else {
547
+ if (single) {
548
+ searching = true;
549
+ }
550
+ }
551
+ } else {
552
+ if (single) {
553
+ searching = true;
554
+ }
555
+ }
556
+ }
557
+
558
+ function handleOptionMouseDown(option) {
559
+ if (single) {
560
+ add(option);
561
+ } else {
562
+ isSelected(option) ? remove(option) : add(option);
563
+ }
564
+ }
565
+
566
+ const removeAll = () => {
567
+ fire("remove", { token: value });
568
+ fire("change", { token: value, type: `remove` });
569
+ value = single ? null : [];
570
+ searchText = "";
571
+ };
572
+
573
+ const matchesValue = (_value, _option) => {
574
+ if (_value === null || typeof _value == "undefined") {
575
+ return false;
576
+ }
577
+ return (
578
+ `${_value[used_value_key] || _value}` === `${_option[used_value_key]}`
579
+ );
580
+ };
581
+ </script>