svelte-multiselect 4.0.5 → 4.0.6
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/MultiSelect.svelte +97 -66
- package/MultiSelect.svelte.d.ts +2 -1
- package/package.json +15 -14
- package/readme.md +2 -1
package/MultiSelect.svelte
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
<script >import { createEventDispatcher, tick } from 'svelte';
|
|
2
|
-
import { fly } from 'svelte/transition';
|
|
3
2
|
import CircleSpinner from './CircleSpinner.svelte';
|
|
4
3
|
import { CrossIcon, ExpandIcon, DisabledIcon } from './icons';
|
|
5
4
|
import Wiggle from './Wiggle.svelte';
|
|
@@ -36,6 +35,7 @@ export let removeBtnTitle = `Remove`;
|
|
|
36
35
|
export let removeAllTitle = `Remove all`;
|
|
37
36
|
export let defaultDisabledTitle = `This option is disabled`;
|
|
38
37
|
export let allowUserOptions = false;
|
|
38
|
+
export let addOptionMsg = `Create this option...`;
|
|
39
39
|
export let autoScroll = true;
|
|
40
40
|
export let loading = false;
|
|
41
41
|
export let required = false;
|
|
@@ -49,29 +49,26 @@ if (!(options?.length > 0))
|
|
|
49
49
|
if (!Array.isArray(selected))
|
|
50
50
|
console.error(`selected prop must be an array`);
|
|
51
51
|
const dispatch = createEventDispatcher();
|
|
52
|
-
|
|
52
|
+
let activeMsg = false; // controls active state of <li>{addOptionMsg}</li>
|
|
53
|
+
function is_object(item) {
|
|
53
54
|
return typeof item === `object` && !Array.isArray(item) && item !== null;
|
|
54
55
|
}
|
|
55
56
|
// process proto options to full ones with mandatory labels
|
|
56
|
-
$: _options = options.map((
|
|
57
|
-
if (
|
|
58
|
-
const option = { ...
|
|
57
|
+
$: _options = options.map((raw_op) => {
|
|
58
|
+
if (is_object(raw_op)) {
|
|
59
|
+
const option = { ...raw_op };
|
|
59
60
|
if (option.value === undefined)
|
|
60
61
|
option.value = option.label;
|
|
61
62
|
return option;
|
|
62
63
|
}
|
|
63
64
|
else {
|
|
64
|
-
if (![`string`, `number`].includes(typeof
|
|
65
|
-
console.warn(`MultiSelect options must be objects, strings or numbers, got ${typeof
|
|
65
|
+
if (![`string`, `number`].includes(typeof raw_op)) {
|
|
66
|
+
console.warn(`MultiSelect options must be objects, strings or numbers, got ${typeof raw_op}`);
|
|
66
67
|
}
|
|
67
68
|
// even if we logged error above, try to proceed hoping user knows what they're doing
|
|
68
|
-
return { label:
|
|
69
|
+
return { label: raw_op, value: raw_op };
|
|
69
70
|
}
|
|
70
71
|
});
|
|
71
|
-
$: labels = _options.map((op) => op.label);
|
|
72
|
-
$: if (new Set(labels).size !== options.length) {
|
|
73
|
-
console.warn(`Option labels should be unique. Duplicates found: ${labels.filter((label, idx) => labels.indexOf(label) !== idx)}`);
|
|
74
|
-
}
|
|
75
72
|
let wiggle = false;
|
|
76
73
|
$: selectedLabels = selected.map((op) => op.label);
|
|
77
74
|
$: selectedValues = selected.map((op) => op.value);
|
|
@@ -83,38 +80,57 @@ $: if (formValue)
|
|
|
83
80
|
// options matching the current search text
|
|
84
81
|
$: matchingOptions = _options.filter((op) => filterFunc(op, searchText) && !selectedLabels.includes(op.label));
|
|
85
82
|
$: matchingEnabledOptions = matchingOptions.filter((op) => !op.disabled);
|
|
83
|
+
// add an option to selected list
|
|
86
84
|
function add(label) {
|
|
87
85
|
if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
|
|
88
86
|
wiggle = true;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
// to prevent duplicate selection, we could add `&& !selectedLabels.includes(label)`
|
|
88
|
+
if (maxSelect === null || maxSelect === 1 || selected.length < maxSelect) {
|
|
89
|
+
// first check if we find option in the options list
|
|
90
|
+
let option = _options.find((op) => op.label === label);
|
|
91
|
+
if (!option && // this has the side-effect of not allowing to user to add the same
|
|
92
|
+
// custom option twice in append mode
|
|
93
|
+
[true, `append`].includes(allowUserOptions) &&
|
|
94
|
+
searchText.length > 0) {
|
|
95
|
+
// user entered text but no options match, so if allowUserOptions=true | 'append', we create new option
|
|
96
|
+
option = { label: searchText, value: searchText };
|
|
97
|
+
if (allowUserOptions === `append`)
|
|
98
|
+
_options = [..._options, option];
|
|
99
|
+
}
|
|
92
100
|
searchText = ``; // reset search string on selection
|
|
93
|
-
const option = _options.find((op) => op.label === label);
|
|
94
101
|
if (!option) {
|
|
95
102
|
console.error(`MultiSelect: option with label ${label} not found`);
|
|
96
103
|
return;
|
|
97
104
|
}
|
|
98
105
|
if (maxSelect === 1) {
|
|
106
|
+
// for maxselect = 1 we always replace current option with new one
|
|
99
107
|
selected = [option];
|
|
100
108
|
}
|
|
101
109
|
else {
|
|
102
|
-
selected = [
|
|
110
|
+
selected = [...selected, option];
|
|
103
111
|
}
|
|
104
112
|
if (selected.length === maxSelect)
|
|
105
113
|
setOptionsVisible(false);
|
|
114
|
+
else
|
|
115
|
+
input?.focus();
|
|
106
116
|
dispatch(`add`, { option });
|
|
107
117
|
dispatch(`change`, { option, type: `add` });
|
|
108
118
|
}
|
|
109
119
|
}
|
|
120
|
+
// remove an option from selected list
|
|
110
121
|
function remove(label) {
|
|
111
122
|
if (selected.length === 0)
|
|
112
123
|
return;
|
|
113
|
-
|
|
124
|
+
selected.splice(selectedLabels.lastIndexOf(label), 1);
|
|
125
|
+
selected = selected; // Svelte rerender after in-place splice
|
|
126
|
+
const option = _options.find((option) => option.label === label) ??
|
|
127
|
+
// if option with label could not be found but allowUserOptions is truthy,
|
|
128
|
+
// assume it was created by user and create correspondidng option object
|
|
129
|
+
// on the fly for use as event payload
|
|
130
|
+
(allowUserOptions && { label, value: label });
|
|
114
131
|
if (!option) {
|
|
115
132
|
return console.error(`MultiSelect: option with label ${label} not found`);
|
|
116
133
|
}
|
|
117
|
-
selected = selected.filter((option) => label !== option.label);
|
|
118
134
|
dispatch(`remove`, { option });
|
|
119
135
|
dispatch(`change`, { option, type: `remove` });
|
|
120
136
|
}
|
|
@@ -141,16 +157,15 @@ async function handleKeydown(event) {
|
|
|
141
157
|
}
|
|
142
158
|
// on enter key: toggle active option and reset search text
|
|
143
159
|
else if (event.key === `Enter`) {
|
|
160
|
+
event.preventDefault(); // prevent enter key from triggering form submission
|
|
144
161
|
if (activeOption) {
|
|
145
162
|
const { label } = activeOption;
|
|
146
163
|
selectedLabels.includes(label) ? remove(label) : add(label);
|
|
147
164
|
searchText = ``;
|
|
148
165
|
}
|
|
149
|
-
else if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
options = [...options, { label: searchText, value: searchText }];
|
|
153
|
-
searchText = ``;
|
|
166
|
+
else if (allowUserOptions && searchText.length > 0) {
|
|
167
|
+
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
|
|
168
|
+
add(searchText);
|
|
154
169
|
}
|
|
155
170
|
// no active option and no search text means the options dropdown is closed
|
|
156
171
|
// in which case enter means open it
|
|
@@ -159,11 +174,17 @@ async function handleKeydown(event) {
|
|
|
159
174
|
}
|
|
160
175
|
// on up/down arrow keys: update active option
|
|
161
176
|
else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
|
|
162
|
-
if
|
|
163
|
-
|
|
177
|
+
// if no option is active yet, but there are matching options, make first one active
|
|
178
|
+
if (activeOption === null && matchingEnabledOptions.length > 0) {
|
|
164
179
|
activeOption = matchingEnabledOptions[0];
|
|
165
180
|
return;
|
|
166
181
|
}
|
|
182
|
+
else if (allowUserOptions && searchText.length > 0) {
|
|
183
|
+
// if allowUserOptions is truthy and user entered text but no options match, we make
|
|
184
|
+
// <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
|
|
185
|
+
activeMsg = !activeMsg;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
167
188
|
const increment = event.key === `ArrowUp` ? -1 : 1;
|
|
168
189
|
const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
|
|
169
190
|
if (newActiveIdx < 0) {
|
|
@@ -185,10 +206,8 @@ async function handleKeydown(event) {
|
|
|
185
206
|
}
|
|
186
207
|
}
|
|
187
208
|
// on backspace key: remove last selected option
|
|
188
|
-
else if (event.key === `Backspace`) {
|
|
189
|
-
|
|
190
|
-
if (label && !searchText)
|
|
191
|
-
remove(label);
|
|
209
|
+
else if (event.key === `Backspace` && selectedLabels.length > 0 && !searchText) {
|
|
210
|
+
remove(selectedLabels.at(-1));
|
|
192
211
|
}
|
|
193
212
|
}
|
|
194
213
|
const removeAll = () => {
|
|
@@ -305,48 +324,55 @@ display above those of another following shortly after it -->
|
|
|
305
324
|
{/if}
|
|
306
325
|
{/if}
|
|
307
326
|
|
|
308
|
-
{
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
{
|
|
316
|
-
|
|
317
|
-
|
|
327
|
+
<ul class:hidden={!showOptions} class="options {ulOptionsClass}">
|
|
328
|
+
{#each matchingOptions as option, idx}
|
|
329
|
+
{@const { label, disabled, title = null, selectedTitle } = option}
|
|
330
|
+
{@const { disabledTitle = defaultDisabledTitle } = option}
|
|
331
|
+
{@const active = activeOption?.label === label}
|
|
332
|
+
<li
|
|
333
|
+
on:mousedown|stopPropagation
|
|
334
|
+
on:mouseup|stopPropagation={() => {
|
|
335
|
+
if (!disabled) isSelected(label) ? remove(label) : add(label)
|
|
336
|
+
}}
|
|
337
|
+
title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
|
|
338
|
+
class:selected={isSelected(label)}
|
|
339
|
+
class:active
|
|
340
|
+
class:disabled
|
|
341
|
+
class="{liOptionClass} {active ? liActiveOptionClass : ``}"
|
|
342
|
+
on:mouseover={() => {
|
|
343
|
+
if (!disabled) activeOption = option
|
|
344
|
+
}}
|
|
345
|
+
on:focus={() => {
|
|
346
|
+
if (!disabled) activeOption = option
|
|
347
|
+
}}
|
|
348
|
+
on:mouseout={() => (activeOption = null)}
|
|
349
|
+
on:blur={() => (activeOption = null)}
|
|
350
|
+
aria-selected="false"
|
|
351
|
+
>
|
|
352
|
+
<slot name="option" {option} {idx}>
|
|
353
|
+
{option.label}
|
|
354
|
+
</slot>
|
|
355
|
+
</li>
|
|
356
|
+
{:else}
|
|
357
|
+
{#if allowUserOptions && searchText}
|
|
318
358
|
<li
|
|
319
|
-
on:
|
|
320
|
-
on:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
class:disabled
|
|
328
|
-
class="{liOptionClass} {active ? liActiveOptionClass : ``}"
|
|
329
|
-
on:mouseover={() => {
|
|
330
|
-
if (disabled) return
|
|
331
|
-
activeOption = option
|
|
332
|
-
}}
|
|
333
|
-
on:focus={() => {
|
|
334
|
-
if (disabled) return
|
|
335
|
-
activeOption = option
|
|
336
|
-
}}
|
|
337
|
-
on:mouseout={() => (activeOption = null)}
|
|
338
|
-
on:blur={() => (activeOption = null)}
|
|
359
|
+
on:mousedown|stopPropagation
|
|
360
|
+
on:mouseup|stopPropagation={() => add(searchText)}
|
|
361
|
+
title={addOptionMsg}
|
|
362
|
+
class:active={activeMsg}
|
|
363
|
+
on:mouseover={() => (activeMsg = true)}
|
|
364
|
+
on:focus={() => (activeMsg = true)}
|
|
365
|
+
on:mouseout={() => (activeMsg = false)}
|
|
366
|
+
on:blur={() => (activeMsg = false)}
|
|
339
367
|
aria-selected="false"
|
|
340
368
|
>
|
|
341
|
-
|
|
342
|
-
{option.label}
|
|
343
|
-
</slot>
|
|
369
|
+
{addOptionMsg}
|
|
344
370
|
</li>
|
|
345
371
|
{:else}
|
|
346
372
|
<span>{noOptionsMsg}</span>
|
|
347
|
-
{/
|
|
348
|
-
|
|
349
|
-
|
|
373
|
+
{/if}
|
|
374
|
+
{/each}
|
|
375
|
+
</ul>
|
|
350
376
|
</div>
|
|
351
377
|
|
|
352
378
|
<style>
|
|
@@ -455,9 +481,14 @@ display above those of another following shortly after it -->
|
|
|
455
481
|
max-height: var(--sms-options-max-height, 50vh);
|
|
456
482
|
overscroll-behavior: var(--sms-options-overscroll, none);
|
|
457
483
|
box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);
|
|
484
|
+
transition: all 0.2s;
|
|
485
|
+
opacity: 1;
|
|
486
|
+
transform: translateY(0);
|
|
458
487
|
}
|
|
459
488
|
:where(div.multiselect > ul.options.hidden) {
|
|
460
489
|
visibility: hidden;
|
|
490
|
+
opacity: 0;
|
|
491
|
+
transform: translateY(50px);
|
|
461
492
|
}
|
|
462
493
|
:where(div.multiselect > ul.options > li) {
|
|
463
494
|
padding: 3pt 2ex;
|
package/MultiSelect.svelte.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ declare const __propDef: {
|
|
|
31
31
|
removeAllTitle?: string | undefined;
|
|
32
32
|
defaultDisabledTitle?: string | undefined;
|
|
33
33
|
allowUserOptions?: boolean | "append" | undefined;
|
|
34
|
+
addOptionMsg?: string | undefined;
|
|
34
35
|
autoScroll?: boolean | undefined;
|
|
35
36
|
loading?: boolean | undefined;
|
|
36
37
|
required?: boolean | undefined;
|
|
@@ -38,7 +39,7 @@ declare const __propDef: {
|
|
|
38
39
|
invalid?: boolean | undefined;
|
|
39
40
|
};
|
|
40
41
|
events: {
|
|
41
|
-
|
|
42
|
+
mousedown: MouseEvent;
|
|
42
43
|
} & {
|
|
43
44
|
[evt: string]: CustomEvent<any>;
|
|
44
45
|
};
|
package/package.json
CHANGED
|
@@ -5,37 +5,38 @@
|
|
|
5
5
|
"homepage": "https://svelte-multiselect.netlify.app",
|
|
6
6
|
"repository": "https://github.com/janosh/svelte-multiselect",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"version": "4.0.
|
|
8
|
+
"version": "4.0.6",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"svelte": "index.js",
|
|
11
11
|
"bugs": "https://github.com/janosh/svelte-multiselect/issues",
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@sveltejs/adapter-static": "^1.0.0-next.29",
|
|
14
|
-
"@sveltejs/kit": "^1.0.0-next.
|
|
15
|
-
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.
|
|
16
|
-
"@typescript-eslint/eslint-plugin": "^5.
|
|
17
|
-
"@typescript-eslint/parser": "^5.
|
|
18
|
-
"@vitest/ui": "^0.
|
|
19
|
-
"
|
|
14
|
+
"@sveltejs/kit": "^1.0.0-next.308",
|
|
15
|
+
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.41",
|
|
16
|
+
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
|
17
|
+
"@typescript-eslint/parser": "^5.18.0",
|
|
18
|
+
"@vitest/ui": "^0.9.0",
|
|
19
|
+
"c8": "^7.11.0",
|
|
20
|
+
"eslint": "^8.12.0",
|
|
20
21
|
"eslint-plugin-svelte3": "^3.4.1",
|
|
21
22
|
"hastscript": "^7.0.2",
|
|
22
23
|
"jsdom": "^19.0.0",
|
|
23
24
|
"mdsvex": "^0.10.5",
|
|
24
|
-
"playwright": "^1.20.
|
|
25
|
-
"prettier": "^2.6.
|
|
25
|
+
"playwright": "^1.20.2",
|
|
26
|
+
"prettier": "^2.6.2",
|
|
26
27
|
"prettier-plugin-svelte": "^2.6.0",
|
|
27
28
|
"rehype-autolink-headings": "^6.1.1",
|
|
28
29
|
"rehype-slug": "^5.0.1",
|
|
29
|
-
"svelte": "^3.46.
|
|
30
|
+
"svelte": "^3.46.6",
|
|
30
31
|
"svelte-check": "^2.4.6",
|
|
31
32
|
"svelte-github-corner": "^0.1.0",
|
|
32
|
-
"svelte-preprocess": "^4.10.
|
|
33
|
+
"svelte-preprocess": "^4.10.5",
|
|
33
34
|
"svelte-toc": "^0.2.9",
|
|
34
35
|
"svelte2tsx": "^0.5.6",
|
|
35
36
|
"tslib": "^2.3.1",
|
|
36
|
-
"typescript": "^4.6.
|
|
37
|
-
"vite": "^2.
|
|
38
|
-
"vitest": "^0.
|
|
37
|
+
"typescript": "^4.6.3",
|
|
38
|
+
"vite": "^2.9.1",
|
|
39
|
+
"vitest": "^0.9.0"
|
|
39
40
|
},
|
|
40
41
|
"keywords": [
|
|
41
42
|
"svelte",
|
package/readme.md
CHANGED
|
@@ -105,6 +105,7 @@ Full list of props/bindable variables for this component:
|
|
|
105
105
|
| `required` | `false` | Whether forms can be submitted without selecting any options. Aborts submission, is scrolled into view and shows help "Please fill out" message when true and user tries to submit with no options selected. |
|
|
106
106
|
| `autoScroll` | `true` | `false` disables keeping the active dropdown items in view when going up/down the list of options with arrow keys. |
|
|
107
107
|
| `allowUserOptions` | `false` | Whether users are allowed to enter values not in the dropdown list. `true` means add user-defined options to the selected list only, `'append'` means add to both options and selected. |
|
|
108
|
+
| `addOptionMsg` | `'Create this option...'` | Message shown to users after entering text when no options match their query and `allowUserOptions` is truthy. |
|
|
108
109
|
| `loading` | `false` | Whether the component should display a spinner to indicate it's in loading state. Use `<slot name='spinner'>` to specify a custom spinner. |
|
|
109
110
|
| `removeBtnTitle` | `'Remove'` | Title text to display when user hovers over button (cross icon) to remove selected option. |
|
|
110
111
|
| `removeAllTitle` | `'Remove all'` | Title text to display when user hovers over remove-all button. |
|
|
@@ -281,7 +282,7 @@ The second method allows you to pass in custom classes to the important DOM elem
|
|
|
281
282
|
- `liOptionClass`
|
|
282
283
|
- `liActiveOptionClass`
|
|
283
284
|
|
|
284
|
-
This simplified version of the DOM structure of
|
|
285
|
+
This simplified version of the DOM structure of the component shows where these classes are inserted:
|
|
285
286
|
|
|
286
287
|
```svelte
|
|
287
288
|
<div class="multiselect {outerDivClass}">
|