svelte-multiselect 1.2.0 → 2.0.0
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 +182 -107
- package/MultiSelect.svelte.d.ts +11 -4
- package/actions.d.ts +3 -0
- package/actions.js +16 -0
- package/icons/ChevronExpand.svelte +2 -1
- package/icons/Cross.svelte +2 -1
- package/icons/ReadOnly.svelte +2 -1
- package/icons/index.d.ts +3 -0
- package/icons/index.js +3 -0
- package/index.d.ts +14 -0
- package/package.json +16 -15
- package/readme.md +52 -41
package/MultiSelect.svelte
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
<script >import { createEventDispatcher } from 'svelte';
|
|
1
|
+
<script >import { createEventDispatcher, onMount } from 'svelte';
|
|
2
2
|
import { fly } from 'svelte/transition';
|
|
3
|
-
import
|
|
4
|
-
import ExpandIcon from './icons
|
|
5
|
-
|
|
6
|
-
export let
|
|
3
|
+
import { onClickOutside } from './actions';
|
|
4
|
+
import { CrossIcon, ExpandIcon, ReadOnlyIcon } from './icons';
|
|
5
|
+
export let selected = [];
|
|
6
|
+
export let selectedLabels = [];
|
|
7
|
+
export let selectedValues = [];
|
|
7
8
|
export let maxSelect = null; // null means any number of options are selectable
|
|
9
|
+
export let maxSelectMsg = (current, max) => `${current}/${max}`;
|
|
8
10
|
export let readonly = false;
|
|
9
|
-
export let placeholder = ``;
|
|
10
11
|
export let options;
|
|
11
|
-
export let disabledOptions = [];
|
|
12
12
|
export let input = null;
|
|
13
|
+
export let placeholder = undefined;
|
|
14
|
+
export let name = undefined;
|
|
15
|
+
export let id = undefined;
|
|
13
16
|
export let noOptionsMsg = `No matching options`;
|
|
17
|
+
export let activeOption = null;
|
|
14
18
|
export let outerDivClass = ``;
|
|
15
19
|
export let ulTokensClass = ``;
|
|
16
20
|
export let liTokenClass = ``;
|
|
@@ -18,47 +22,89 @@ export let ulOptionsClass = ``;
|
|
|
18
22
|
export let liOptionClass = ``;
|
|
19
23
|
export let removeBtnTitle = `Remove`;
|
|
20
24
|
export let removeAllTitle = `Remove all`;
|
|
25
|
+
export let defaultDisabledTitle = `This option is disabled`;
|
|
21
26
|
if (maxSelect !== null && maxSelect < 0) {
|
|
22
|
-
|
|
27
|
+
console.error(`maxSelect must be null or positive integer, got ${maxSelect}`);
|
|
23
28
|
}
|
|
24
|
-
$: single = maxSelect === 1;
|
|
25
|
-
if (!selected)
|
|
26
|
-
selected = single ? `` : [];
|
|
27
29
|
if (!(options?.length > 0))
|
|
28
30
|
console.error(`MultiSelect missing options`);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if (!Array.isArray(selected))
|
|
32
|
+
console.error(`selected prop must be an array`);
|
|
33
|
+
function isObject(item) {
|
|
34
|
+
return typeof item === `object` && !Array.isArray(item) && item !== null;
|
|
32
35
|
}
|
|
36
|
+
onMount(() => {
|
|
37
|
+
selected = _options.filter((op) => op?.preselected);
|
|
38
|
+
});
|
|
39
|
+
// process proto options to full ones with mandatory labels
|
|
40
|
+
$: _options = options.map((rawOp) => {
|
|
41
|
+
// convert to objects internally if user passed list of strings or numbers as options
|
|
42
|
+
if (isObject(rawOp)) {
|
|
43
|
+
const op = { ...rawOp };
|
|
44
|
+
if (!op.value)
|
|
45
|
+
op.value = op.label;
|
|
46
|
+
return op;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (![`string`, `number`].includes(typeof rawOp)) {
|
|
50
|
+
console.error(`MultiSelect options must be objects, strings or numbers, got ${typeof rawOp}`);
|
|
51
|
+
}
|
|
52
|
+
// even if we logged error above, try to proceed hoping user knows what they're doing
|
|
53
|
+
return { label: rawOp, value: rawOp };
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
$: labels = _options.map((op) => op.label);
|
|
57
|
+
$: if (new Set(labels).size !== options.length) {
|
|
58
|
+
console.error(`Option labels must be unique. Duplicates found: ${labels.filter((label, idx) => labels.indexOf(label) !== idx)}`);
|
|
59
|
+
}
|
|
60
|
+
$: selectedLabels = selected.map((op) => op.label);
|
|
61
|
+
$: selectedValues = selected.map((op) => op.value);
|
|
33
62
|
const dispatch = createEventDispatcher();
|
|
34
|
-
let
|
|
63
|
+
let searchText = ``;
|
|
35
64
|
let showOptions = false;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
65
|
+
// options matching the current search text
|
|
66
|
+
$: matchingOptions = _options.filter((op) => {
|
|
67
|
+
if (!searchText)
|
|
68
|
+
return true;
|
|
69
|
+
return `${op.label}`.toLowerCase().includes(searchText.toLowerCase());
|
|
70
|
+
});
|
|
71
|
+
$: matchingEnabledOptions = matchingOptions.filter((op) => !op.disabled);
|
|
72
|
+
$: if (
|
|
73
|
+
// if there was an active option but it's not in the filtered list of options
|
|
74
|
+
(activeOption &&
|
|
75
|
+
!matchingEnabledOptions.map((op) => op.label).includes(activeOption.label)) ||
|
|
76
|
+
// or there's no active option but the user entered search text
|
|
40
77
|
(!activeOption && searchText))
|
|
41
|
-
|
|
42
|
-
|
|
78
|
+
// make the first filtered option active
|
|
79
|
+
activeOption = matchingEnabledOptions[0];
|
|
80
|
+
function add(label) {
|
|
43
81
|
if (!readonly &&
|
|
44
|
-
!
|
|
45
|
-
//
|
|
46
|
-
(maxSelect
|
|
82
|
+
!selectedLabels.includes(label) &&
|
|
83
|
+
// for maxselect = 1 we always replace current token with new selection
|
|
84
|
+
(maxSelect == null || maxSelect == 1 || selected.length < maxSelect)) {
|
|
47
85
|
searchText = ``; // reset search string on selection
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
86
|
+
const token = _options.find((op) => op.label === label);
|
|
87
|
+
if (!token) {
|
|
88
|
+
console.error(`MultiSelect: option with label ${label} not found`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (maxSelect === 1) {
|
|
92
|
+
selected = [token];
|
|
53
93
|
}
|
|
94
|
+
else {
|
|
95
|
+
selected = [token, ...selected];
|
|
96
|
+
}
|
|
97
|
+
if (selected.length === maxSelect)
|
|
98
|
+
setOptionsVisible(false);
|
|
54
99
|
dispatch(`add`, { token });
|
|
55
100
|
dispatch(`change`, { token, type: `add` });
|
|
56
101
|
}
|
|
57
102
|
}
|
|
58
|
-
function remove(
|
|
59
|
-
if (
|
|
103
|
+
function remove(label) {
|
|
104
|
+
if (selected.length === 0 || readonly)
|
|
60
105
|
return;
|
|
61
|
-
selected = selected.filter((
|
|
106
|
+
selected = selected.filter((token) => label !== token.label);
|
|
107
|
+
const token = _options.find((option) => option.label === label);
|
|
62
108
|
dispatch(`remove`, { token });
|
|
63
109
|
dispatch(`change`, { token, type: `remove` });
|
|
64
110
|
}
|
|
@@ -69,83 +115,104 @@ function setOptionsVisible(show) {
|
|
|
69
115
|
showOptions = show;
|
|
70
116
|
if (show)
|
|
71
117
|
input?.focus();
|
|
118
|
+
else {
|
|
119
|
+
input?.blur();
|
|
120
|
+
activeOption = null;
|
|
121
|
+
}
|
|
72
122
|
}
|
|
123
|
+
// handle all keyboard events this component receives
|
|
73
124
|
function handleKeydown(event) {
|
|
125
|
+
// on escape: dismiss options dropdown and reset search text
|
|
74
126
|
if (event.key === `Escape`) {
|
|
75
127
|
setOptionsVisible(false);
|
|
76
128
|
searchText = ``;
|
|
77
129
|
}
|
|
130
|
+
// on enter key: toggle active option and reset search text
|
|
78
131
|
else if (event.key === `Enter`) {
|
|
79
132
|
if (activeOption) {
|
|
80
|
-
|
|
133
|
+
const { label, disabled } = activeOption;
|
|
134
|
+
if (disabled)
|
|
81
135
|
return;
|
|
82
|
-
|
|
136
|
+
selectedLabels.includes(label) ? remove(label) : add(label);
|
|
83
137
|
searchText = ``;
|
|
84
|
-
} // no active option means the options
|
|
138
|
+
} // no active option means the options dropdown is closed in which case enter means open it
|
|
85
139
|
else
|
|
86
140
|
setOptionsVisible(true);
|
|
87
141
|
}
|
|
142
|
+
// on up/down arrow keys: update active option
|
|
88
143
|
else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
|
|
144
|
+
if (activeOption === null) {
|
|
145
|
+
// if no option is active yet, make first one active
|
|
146
|
+
activeOption = matchingEnabledOptions[0];
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
89
149
|
const increment = event.key === `ArrowUp` ? -1 : 1;
|
|
90
|
-
const newActiveIdx =
|
|
150
|
+
const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
|
|
91
151
|
if (newActiveIdx < 0) {
|
|
92
|
-
|
|
152
|
+
// wrap around top
|
|
153
|
+
activeOption = matchingEnabledOptions[matchingEnabledOptions.length - 1];
|
|
154
|
+
// wrap around bottom
|
|
93
155
|
}
|
|
94
|
-
else {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
else
|
|
98
|
-
activeOption = filteredOptions[newActiveIdx];
|
|
156
|
+
else if (newActiveIdx === matchingEnabledOptions.length) {
|
|
157
|
+
activeOption = matchingEnabledOptions[0];
|
|
158
|
+
// default case
|
|
99
159
|
}
|
|
160
|
+
else
|
|
161
|
+
activeOption = matchingEnabledOptions[newActiveIdx];
|
|
100
162
|
}
|
|
101
163
|
else if (event.key === `Backspace`) {
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
}
|
|
164
|
+
const label = selectedLabels.pop();
|
|
165
|
+
if (label && !searchText)
|
|
166
|
+
remove(label);
|
|
106
167
|
}
|
|
107
168
|
}
|
|
108
169
|
const removeAll = () => {
|
|
109
170
|
dispatch(`remove`, { token: selected });
|
|
110
171
|
dispatch(`change`, { token: selected, type: `remove` });
|
|
111
|
-
selected =
|
|
172
|
+
selected = [];
|
|
112
173
|
searchText = ``;
|
|
113
174
|
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
else
|
|
121
|
-
return selected.includes(option);
|
|
175
|
+
$: isSelected = (label) => selectedLabels.includes(label);
|
|
176
|
+
const handleEnterAndSpaceKeys = (handler) => (event) => {
|
|
177
|
+
if ([`Enter`, `Space`].includes(event.code)) {
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
handler();
|
|
180
|
+
}
|
|
122
181
|
};
|
|
123
182
|
</script>
|
|
124
183
|
|
|
125
|
-
<!-- z-index: 2 when showOptions is
|
|
184
|
+
<!-- z-index: 2 when showOptions is true ensures the ul.tokens of one <MultiSelect />
|
|
185
|
+
display above those of another following shortly after it -->
|
|
126
186
|
<div
|
|
187
|
+
{id}
|
|
127
188
|
class="multiselect {outerDivClass}"
|
|
128
189
|
class:readonly
|
|
129
|
-
class:single
|
|
130
|
-
style={showOptions ? `z-index: 2;` :
|
|
131
|
-
on:mouseup|stopPropagation={() => setOptionsVisible(true)}
|
|
132
|
-
|
|
190
|
+
class:single={maxSelect == 1}
|
|
191
|
+
style={showOptions ? `z-index: 2;` : undefined}
|
|
192
|
+
on:mouseup|stopPropagation={() => setOptionsVisible(true)}
|
|
193
|
+
use:onClickOutside={() => setOptionsVisible(false)}
|
|
194
|
+
use:onClickOutside={() => dispatch(`blur`)}
|
|
195
|
+
>
|
|
196
|
+
<ExpandIcon height="14pt" style="padding: 0 3pt 0 1pt;" />
|
|
133
197
|
<ul class="tokens {ulTokensClass}">
|
|
134
|
-
{#if
|
|
198
|
+
{#if maxSelect == 1 && selected[0]?.label}
|
|
135
199
|
<span on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}>
|
|
136
|
-
{selected}
|
|
200
|
+
{selected[0].label}
|
|
137
201
|
</span>
|
|
138
|
-
{:else
|
|
139
|
-
{#each selected as
|
|
202
|
+
{:else}
|
|
203
|
+
{#each selected as { label }}
|
|
140
204
|
<li
|
|
141
205
|
class={liTokenClass}
|
|
142
|
-
on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
|
|
143
|
-
|
|
206
|
+
on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
|
|
207
|
+
>
|
|
208
|
+
{label}
|
|
144
209
|
{#if !readonly}
|
|
145
210
|
<button
|
|
146
|
-
on:mouseup|stopPropagation={() => remove(
|
|
211
|
+
on:mouseup|stopPropagation={() => remove(label)}
|
|
212
|
+
on:keydown={handleEnterAndSpaceKeys(() => remove(label))}
|
|
147
213
|
type="button"
|
|
148
|
-
title="{removeBtnTitle} {
|
|
214
|
+
title="{removeBtnTitle} {label}"
|
|
215
|
+
>
|
|
149
216
|
<CrossIcon height="12pt" />
|
|
150
217
|
</button>
|
|
151
218
|
{/if}
|
|
@@ -159,19 +226,23 @@ $: isSelected = (option) => {
|
|
|
159
226
|
on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
|
|
160
227
|
on:keydown={handleKeydown}
|
|
161
228
|
on:focus={() => setOptionsVisible(true)}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
229
|
+
{name}
|
|
230
|
+
placeholder={selectedLabels.length ? `` : placeholder}
|
|
231
|
+
/>
|
|
165
232
|
</ul>
|
|
166
233
|
{#if readonly}
|
|
167
234
|
<ReadOnlyIcon height="14pt" />
|
|
168
|
-
{:else}
|
|
235
|
+
{:else if selected.length > 0}
|
|
236
|
+
{#if maxSelect !== null && maxSelect > 1}
|
|
237
|
+
<span style="padding: 0 3pt;">{maxSelectMsg(selected.length, maxSelect)}</span>
|
|
238
|
+
{/if}
|
|
169
239
|
<button
|
|
170
240
|
type="button"
|
|
171
241
|
class="remove-all"
|
|
172
242
|
title={removeAllTitle}
|
|
173
243
|
on:mouseup|stopPropagation={removeAll}
|
|
174
|
-
|
|
244
|
+
on:keydown={handleEnterAndSpaceKeys(removeAll)}
|
|
245
|
+
>
|
|
175
246
|
<CrossIcon height="14pt" />
|
|
176
247
|
</button>
|
|
177
248
|
{/if}
|
|
@@ -180,20 +251,22 @@ $: isSelected = (option) => {
|
|
|
180
251
|
<ul
|
|
181
252
|
class="options {ulOptionsClass}"
|
|
182
253
|
class:hidden={!showOptions}
|
|
183
|
-
transition:fly={{ duration: 300, y: 40 }}
|
|
184
|
-
|
|
254
|
+
transition:fly|local={{ duration: 300, y: 40 }}
|
|
255
|
+
>
|
|
256
|
+
{#each matchingOptions as { label, disabled, title = '', selectedTitle, disabledTitle = defaultDisabledTitle }}
|
|
185
257
|
<li
|
|
186
258
|
on:mouseup|preventDefault|stopPropagation
|
|
187
259
|
on:mousedown|preventDefault|stopPropagation={() => {
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
isSelected(option) ? remove(option) : add(option)
|
|
260
|
+
if (disabled) return
|
|
261
|
+
isSelected(label) ? remove(label) : add(label)
|
|
191
262
|
}}
|
|
192
|
-
class:selected={isSelected(
|
|
193
|
-
class:active={activeOption ===
|
|
194
|
-
class:disabled
|
|
195
|
-
|
|
196
|
-
{
|
|
263
|
+
class:selected={isSelected(label)}
|
|
264
|
+
class:active={activeOption?.label === label}
|
|
265
|
+
class:disabled
|
|
266
|
+
title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
|
|
267
|
+
class={liOptionClass}
|
|
268
|
+
>
|
|
269
|
+
{label}
|
|
197
270
|
</li>
|
|
198
271
|
{:else}
|
|
199
272
|
{noOptionsMsg}
|
|
@@ -203,7 +276,7 @@ $: isSelected = (option) => {
|
|
|
203
276
|
</div>
|
|
204
277
|
|
|
205
278
|
<style>
|
|
206
|
-
.multiselect {
|
|
279
|
+
:where(.multiselect) {
|
|
207
280
|
position: relative;
|
|
208
281
|
margin: 1em 0;
|
|
209
282
|
border: var(--sms-border, 1pt solid lightgray);
|
|
@@ -212,15 +285,16 @@ $: isSelected = (option) => {
|
|
|
212
285
|
min-height: 18pt;
|
|
213
286
|
display: flex;
|
|
214
287
|
cursor: text;
|
|
288
|
+
padding: 0 3pt;
|
|
215
289
|
}
|
|
216
|
-
.multiselect:focus-within {
|
|
290
|
+
:where(.multiselect:focus-within) {
|
|
217
291
|
border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
|
|
218
292
|
}
|
|
219
|
-
.multiselect.readonly {
|
|
293
|
+
:where(.multiselect.readonly) {
|
|
220
294
|
background: var(--sms-readonly-bg, lightgray);
|
|
221
295
|
}
|
|
222
296
|
|
|
223
|
-
ul.tokens > li {
|
|
297
|
+
:where(ul.tokens > li) {
|
|
224
298
|
background: var(--sms-token-bg, var(--sms-active-color, cornflowerblue));
|
|
225
299
|
align-items: center;
|
|
226
300
|
border-radius: 4pt;
|
|
@@ -231,19 +305,14 @@ $: isSelected = (option) => {
|
|
|
231
305
|
white-space: nowrap;
|
|
232
306
|
height: 16pt;
|
|
233
307
|
}
|
|
234
|
-
ul.tokens > li button,
|
|
235
|
-
button.remove-all {
|
|
308
|
+
:where(ul.tokens > li button, button.remove-all) {
|
|
236
309
|
align-items: center;
|
|
237
310
|
border-radius: 50%;
|
|
238
311
|
display: flex;
|
|
239
312
|
cursor: pointer;
|
|
240
313
|
transition: 0.2s;
|
|
241
314
|
}
|
|
242
|
-
|
|
243
|
-
button.remove-all:hover {
|
|
244
|
-
color: var(--sms-remove-x-hover-color, lightgray);
|
|
245
|
-
}
|
|
246
|
-
button {
|
|
315
|
+
:where(button) {
|
|
247
316
|
color: inherit;
|
|
248
317
|
background: transparent;
|
|
249
318
|
border: none;
|
|
@@ -251,18 +320,24 @@ $: isSelected = (option) => {
|
|
|
251
320
|
outline: none;
|
|
252
321
|
padding: 0 2pt;
|
|
253
322
|
}
|
|
323
|
+
:where(ul.tokens > li button:hover, button.remove-all:hover) {
|
|
324
|
+
color: var(--sms-remove-x-hover-focus-color, lightskyblue);
|
|
325
|
+
}
|
|
326
|
+
:where(button:focus) {
|
|
327
|
+
color: var(--sms-remove-x-hover-focus-color, lightskyblue);
|
|
328
|
+
transform: scale(1.04);
|
|
329
|
+
}
|
|
254
330
|
|
|
255
|
-
.multiselect input {
|
|
331
|
+
:where(.multiselect input) {
|
|
256
332
|
border: none;
|
|
257
333
|
outline: none;
|
|
258
334
|
background: none;
|
|
259
|
-
/* needed to hide red shadow around required inputs in some browsers */
|
|
260
|
-
box-shadow: none;
|
|
261
335
|
color: var(--sms-text-color, inherit);
|
|
262
|
-
flex: 1;
|
|
336
|
+
flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
|
|
337
|
+
min-width: 2em;
|
|
263
338
|
}
|
|
264
339
|
|
|
265
|
-
ul.tokens {
|
|
340
|
+
:where(ul.tokens) {
|
|
266
341
|
display: flex;
|
|
267
342
|
padding: 0;
|
|
268
343
|
margin: 0;
|
|
@@ -270,7 +345,7 @@ $: isSelected = (option) => {
|
|
|
270
345
|
flex: 1;
|
|
271
346
|
}
|
|
272
347
|
|
|
273
|
-
ul.options {
|
|
348
|
+
:where(ul.options) {
|
|
274
349
|
list-style: none;
|
|
275
350
|
max-height: 50vh;
|
|
276
351
|
padding: 0;
|
|
@@ -281,14 +356,14 @@ $: isSelected = (option) => {
|
|
|
281
356
|
overflow: auto;
|
|
282
357
|
background: var(--sms-options-bg, white);
|
|
283
358
|
}
|
|
284
|
-
ul.options.hidden {
|
|
359
|
+
:where(ul.options.hidden) {
|
|
285
360
|
visibility: hidden;
|
|
286
361
|
}
|
|
287
|
-
ul.options li {
|
|
362
|
+
:where(ul.options li) {
|
|
288
363
|
padding: 3pt 2ex;
|
|
289
364
|
cursor: pointer;
|
|
290
365
|
}
|
|
291
|
-
ul.options li.selected {
|
|
366
|
+
:where(ul.options li.selected) {
|
|
292
367
|
border-left: var(
|
|
293
368
|
--sms-li-selected-border-left,
|
|
294
369
|
3pt solid var(--sms-selected-color, green)
|
|
@@ -296,22 +371,22 @@ $: isSelected = (option) => {
|
|
|
296
371
|
background: var(--sms-li-selected-bg, inherit);
|
|
297
372
|
color: var(--sms-li-selected-color, inherit);
|
|
298
373
|
}
|
|
299
|
-
ul.options li:not(.selected):hover {
|
|
374
|
+
:where(ul.options li:not(.selected):hover) {
|
|
300
375
|
border-left: var(
|
|
301
376
|
--sms-li-not-selected-hover-border-left,
|
|
302
377
|
3pt solid var(--sms-active-color, cornflowerblue)
|
|
303
378
|
);
|
|
304
379
|
border-left: 3pt solid var(--blue);
|
|
305
380
|
}
|
|
306
|
-
ul.options li.active {
|
|
381
|
+
:where(ul.options li.active) {
|
|
307
382
|
background: var(--sms-li-active-bg, var(--sms-active-color, cornflowerblue));
|
|
308
383
|
}
|
|
309
|
-
ul.options li.disabled {
|
|
384
|
+
:where(ul.options li.disabled) {
|
|
310
385
|
background: var(--sms-li-disabled-bg, #f5f5f6);
|
|
311
386
|
color: var(--sms-li-disabled-text, #b8b8b8);
|
|
312
387
|
cursor: not-allowed;
|
|
313
388
|
}
|
|
314
|
-
ul.options li.disabled:hover {
|
|
389
|
+
:where(ul.options li.disabled:hover) {
|
|
315
390
|
border-left: unset;
|
|
316
391
|
}
|
|
317
392
|
</style>
|
package/MultiSelect.svelte.d.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { SvelteComponentTyped } from "svelte";
|
|
2
|
+
import type { Option, Primitive, ProtoOption } from './';
|
|
2
3
|
declare const __propDef: {
|
|
3
4
|
props: {
|
|
4
|
-
selected
|
|
5
|
+
selected?: Option[] | undefined;
|
|
6
|
+
selectedLabels?: Primitive[] | undefined;
|
|
7
|
+
selectedValues?: Primitive[] | undefined;
|
|
5
8
|
maxSelect?: number | null | undefined;
|
|
9
|
+
maxSelectMsg?: ((current: number, max: number) => string) | undefined;
|
|
6
10
|
readonly?: boolean | undefined;
|
|
7
|
-
|
|
8
|
-
options: (string | number)[];
|
|
9
|
-
disabledOptions?: (string | number)[] | undefined;
|
|
11
|
+
options: ProtoOption[];
|
|
10
12
|
input?: HTMLInputElement | null | undefined;
|
|
13
|
+
placeholder?: string | undefined;
|
|
14
|
+
name?: string | undefined;
|
|
15
|
+
id?: string | undefined;
|
|
11
16
|
noOptionsMsg?: string | undefined;
|
|
17
|
+
activeOption?: Option | null | undefined;
|
|
12
18
|
outerDivClass?: string | undefined;
|
|
13
19
|
ulTokensClass?: string | undefined;
|
|
14
20
|
liTokenClass?: string | undefined;
|
|
@@ -16,6 +22,7 @@ declare const __propDef: {
|
|
|
16
22
|
liOptionClass?: string | undefined;
|
|
17
23
|
removeBtnTitle?: string | undefined;
|
|
18
24
|
removeAllTitle?: string | undefined;
|
|
25
|
+
defaultDisabledTitle?: string | undefined;
|
|
19
26
|
};
|
|
20
27
|
events: {
|
|
21
28
|
mouseup: MouseEvent;
|
package/actions.d.ts
ADDED
package/actions.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function onClickOutside(node, cb) {
|
|
2
|
+
const dispatchOnClickOutside = (event) => {
|
|
3
|
+
const clickWasOutside = node && !node.contains(event.target);
|
|
4
|
+
if (clickWasOutside && !event.defaultPrevented) {
|
|
5
|
+
node.dispatchEvent(new CustomEvent(`clickOutside`));
|
|
6
|
+
if (cb)
|
|
7
|
+
cb();
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
document.addEventListener(`click`, dispatchOnClickOutside);
|
|
11
|
+
return {
|
|
12
|
+
destroy() {
|
|
13
|
+
document.removeEventListener(`click`, dispatchOnClickOutside);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -5,5 +5,6 @@ export let style = ``;
|
|
|
5
5
|
|
|
6
6
|
<svg {width} {height} {style} fill="currentColor" viewBox="0 0 16 16">
|
|
7
7
|
<path
|
|
8
|
-
d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"
|
|
8
|
+
d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"
|
|
9
|
+
/>
|
|
9
10
|
</svg>
|
package/icons/Cross.svelte
CHANGED
|
@@ -5,5 +5,6 @@ export let style = ``;
|
|
|
5
5
|
|
|
6
6
|
<svg {width} {height} {style} viewBox="0 0 20 20" fill="currentColor">
|
|
7
7
|
<path
|
|
8
|
-
d="M10 1.6a8.4 8.4 0 100 16.8 8.4 8.4 0 000-16.8zm4.789 11.461L13.06 14.79 10 11.729l-3.061 3.06L5.21 13.06 8.272 10 5.211 6.939 6.94 5.211 10 8.271l3.061-3.061 1.729 1.729L11.728 10l3.061 3.061z"
|
|
8
|
+
d="M10 1.6a8.4 8.4 0 100 16.8 8.4 8.4 0 000-16.8zm4.789 11.461L13.06 14.79 10 11.729l-3.061 3.06L5.21 13.06 8.272 10 5.211 6.939 6.94 5.211 10 8.271l3.061-3.061 1.729 1.729L11.728 10l3.061 3.061z"
|
|
9
|
+
/>
|
|
9
10
|
</svg>
|
package/icons/ReadOnly.svelte
CHANGED
|
@@ -6,5 +6,6 @@ export let style = ``;
|
|
|
6
6
|
<svg {width} {height} {style} viewBox="0 0 24 24" fill="currentColor">
|
|
7
7
|
<path fill="none" d="M0 0h24v24H0V0z" />
|
|
8
8
|
<path
|
|
9
|
-
d="M14.48 11.95c.17.02.34.05.52.05 2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4c0 .18.03.35.05.52l3.43 3.43zm2.21 2.21L22.53 20H23v-2c0-2.14-3.56-3.5-6.31-3.84zM0 3.12l4 4V10H1v2h3v3h2v-3h2.88l2.51 2.51C9.19 15.11 7 16.3 7 18v2h9.88l4 4 1.41-1.41L1.41 1.71 0 3.12zM6.88 10H6v-.88l.88.88z"
|
|
9
|
+
d="M14.48 11.95c.17.02.34.05.52.05 2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4c0 .18.03.35.05.52l3.43 3.43zm2.21 2.21L22.53 20H23v-2c0-2.14-3.56-3.5-6.31-3.84zM0 3.12l4 4V10H1v2h3v3h2v-3h2.88l2.51 2.51C9.19 15.11 7 16.3 7 18v2h9.88l4 4 1.41-1.41L1.41 1.71 0 3.12zM6.88 10H6v-.88l.88.88z"
|
|
10
|
+
/>
|
|
10
11
|
</svg>
|
package/icons/index.d.ts
ADDED
package/icons/index.js
ADDED
package/index.d.ts
CHANGED
|
@@ -1 +1,15 @@
|
|
|
1
1
|
export { default } from './MultiSelect.svelte';
|
|
2
|
+
export declare type Primitive = string | number;
|
|
3
|
+
export declare type Option = {
|
|
4
|
+
label: Primitive;
|
|
5
|
+
value: Primitive;
|
|
6
|
+
title?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
preselected?: boolean;
|
|
9
|
+
disabledTitle?: string;
|
|
10
|
+
selectedTitle?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
};
|
|
13
|
+
export declare type ProtoOption = Primitive | (Omit<Option, `value`> & {
|
|
14
|
+
value?: Primitive;
|
|
15
|
+
});
|
package/package.json
CHANGED
|
@@ -5,33 +5,33 @@
|
|
|
5
5
|
"homepage": "https://svelte-multiselect.netlify.app",
|
|
6
6
|
"repository": "https://github.com/janosh/svelte-multiselect",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"version": "
|
|
8
|
+
"version": "2.0.0",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"svelte": "MultiSelect.svelte",
|
|
11
11
|
"bugs": {
|
|
12
12
|
"url": "https://github.com/janosh/svelte-multiselect/issues"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@sveltejs/adapter-static": "^1.0.0-next.
|
|
16
|
-
"@sveltejs/kit": "^1.0.0-next.
|
|
17
|
-
"@typescript-eslint/eslint-plugin": "^5.
|
|
18
|
-
"@typescript-eslint/parser": "^5.
|
|
19
|
-
"eslint": "^8.
|
|
15
|
+
"@sveltejs/adapter-static": "^1.0.0-next.22",
|
|
16
|
+
"@sveltejs/kit": "^1.0.0-next.202",
|
|
17
|
+
"@typescript-eslint/eslint-plugin": "^5.7.0",
|
|
18
|
+
"@typescript-eslint/parser": "^5.7.0",
|
|
19
|
+
"eslint": "^8.5.0",
|
|
20
20
|
"eslint-plugin-svelte3": "^3.2.1",
|
|
21
21
|
"hastscript": "^7.0.2",
|
|
22
22
|
"mdsvex": "^0.9.8",
|
|
23
|
-
"prettier": "^2.
|
|
24
|
-
"prettier-plugin-svelte": "^2.
|
|
23
|
+
"prettier": "^2.5.1",
|
|
24
|
+
"prettier-plugin-svelte": "^2.5.1",
|
|
25
25
|
"rehype-autolink-headings": "^6.1.0",
|
|
26
26
|
"rehype-slug": "^5.0.0",
|
|
27
|
-
"svelte": "^3.
|
|
28
|
-
"svelte-check": "^2.2.
|
|
29
|
-
"svelte-preprocess": "^4.
|
|
30
|
-
"svelte-toc": "^0.1.
|
|
31
|
-
"svelte2tsx": "^0.4.
|
|
27
|
+
"svelte": "^3.44.3",
|
|
28
|
+
"svelte-check": "^2.2.11",
|
|
29
|
+
"svelte-preprocess": "^4.10.1",
|
|
30
|
+
"svelte-toc": "^0.1.10",
|
|
31
|
+
"svelte2tsx": "^0.4.12",
|
|
32
32
|
"tslib": "^2.3.1",
|
|
33
|
-
"typescript": "^4.4
|
|
34
|
-
"vite": "^2.
|
|
33
|
+
"typescript": "^4.5.4",
|
|
34
|
+
"vite": "^2.7.3"
|
|
35
35
|
},
|
|
36
36
|
"keywords": [
|
|
37
37
|
"svelte",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"exports": {
|
|
47
47
|
"./package.json": "./package.json",
|
|
48
48
|
"./MultiSelect.svelte": "./MultiSelect.svelte",
|
|
49
|
+
"./actions": "./actions.js",
|
|
49
50
|
".": "./index.js"
|
|
50
51
|
}
|
|
51
52
|
}
|
package/readme.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
<!-- remove above in docs -->
|
|
22
22
|
|
|
23
|
-
Keyboard-friendly, zero-dependency multi-select Svelte component
|
|
23
|
+
**Keyboard-friendly, zero-dependency multi-select Svelte component.**
|
|
24
24
|
|
|
25
25
|
## Key Features
|
|
26
26
|
|
|
@@ -29,7 +29,7 @@ Keyboard-friendly, zero-dependency multi-select Svelte component.
|
|
|
29
29
|
- **Searchable:** start typing to filter options
|
|
30
30
|
- **Tagging:** selected options are recorded as tags within the text input
|
|
31
31
|
- **Server-side rendering:** no reliance on browser objects like `window` or `document`
|
|
32
|
-
- **Configurable:** see
|
|
32
|
+
- **Configurable:** see [props](#props)
|
|
33
33
|
- **No dependencies:** needs only Svelte as dev dependency
|
|
34
34
|
- **Keyboard friendly** for mouse-less form completion
|
|
35
35
|
|
|
@@ -64,7 +64,7 @@ yarn add -D svelte-multiselect
|
|
|
64
64
|
|
|
65
65
|
Favorite Web Frameworks?
|
|
66
66
|
|
|
67
|
-
{JSON.stringify(selected
|
|
67
|
+
<code>selected = {JSON.stringify(selected)}</code>
|
|
68
68
|
|
|
69
69
|
<MultiSelect bind:selected options={webFrameworks} />
|
|
70
70
|
```
|
|
@@ -75,17 +75,21 @@ Full list of props/bindable variables for this component:
|
|
|
75
75
|
|
|
76
76
|
<div class="table">
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
|
80
|
-
|
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
78
|
+
<!-- prettier-ignore -->
|
|
79
|
+
| name | default | description |
|
|
80
|
+
| :--------------- | :--------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `options` | required prop | Array of strings/numbers or `Option` objects that will be listed in the dropdown. See `src/lib/index.ts` for admissible fields. The `label` is the only mandatory one. It must also be unique. |
|
|
82
|
+
| `activeOption` | `null` | Currently active option, i.e. the one the user currently hovers or navigated to with arrow keys. |
|
|
83
|
+
| `maxSelect` | `null` | Positive integer to limit the number of options users can pick. `null` means no limit. |
|
|
84
|
+
| `maxSelectMsg` | ``(current: number, max: number) => `${current}/${max}` `` | Function that returns a string informing the user how many of the maximum allowed options they have currently selected. Return empty string to disable, i.e. `() => ''`. |
|
|
85
|
+
| `selected` | `[]` | Array of currently/pre-selected options when binding/passing as props respectively. |
|
|
86
|
+
| `selectedLabels` | `[]` | Labels of currently selected options. |
|
|
87
|
+
| `selectedValues` | `[]` | Values of currently selected options. |
|
|
88
|
+
| `readonly` | `false` | Disable the component. It will still be rendered but users won't be able to interact with it. |
|
|
89
|
+
| `placeholder` | `undefined` | String shown in the text input when no option is selected. |
|
|
90
|
+
| `input` | `undefined` | Handle to the `<input>` DOM node. |
|
|
91
|
+
| `name` | `undefined` | Passed to the `<input>` for associating HTML form `<label>`s with this component. E.g. clicking a `<label>` with same name will focus this component. |
|
|
92
|
+
| `id` | `undefined` | Applied to the top-level `<div>` e.g. for `document.getElementById()`. |
|
|
89
93
|
|
|
90
94
|
</div>
|
|
91
95
|
|
|
@@ -102,25 +106,16 @@ Full list of props/bindable variables for this component:
|
|
|
102
106
|
|
|
103
107
|
### Examples
|
|
104
108
|
|
|
105
|
-
-
|
|
106
|
-
- `on:
|
|
107
|
-
-
|
|
109
|
+
<!-- prettier-ignore -->
|
|
110
|
+
- `on:add={(event) => console.log(event.detail.token.label)}`
|
|
111
|
+
- `on:remove={(event) => console.log(event.detail.token.label)}`.
|
|
112
|
+
- ``on:change={(event) => console.log(`${event.detail.type}: '${event.detail.token.label}'`)}``
|
|
108
113
|
- `on:blur={yourFunctionHere}`
|
|
109
114
|
|
|
110
115
|
```svelte
|
|
111
116
|
<MultiSelect
|
|
112
|
-
on:change={(e) => alert(`You ${e.detail.type}ed '${e.detail.token}'`)}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
## Want to contribute?
|
|
116
|
-
|
|
117
|
-
To submit a PR, clone the repo, install dependencies and start the dev server to try out your changes first.
|
|
118
|
-
|
|
119
|
-
```sh
|
|
120
|
-
git clone https://github.com/janosh/svelte-multiselect
|
|
121
|
-
cd svelte-multiselect
|
|
122
|
-
yarn
|
|
123
|
-
yarn dev
|
|
117
|
+
on:change={(e) => alert(`You ${e.detail.type}ed '${e.detail.token.label}'`)}
|
|
118
|
+
/>
|
|
124
119
|
```
|
|
125
120
|
|
|
126
121
|
## Styling
|
|
@@ -134,10 +129,11 @@ The first, if you only want to make small adjustments, allows you to pass the fo
|
|
|
134
129
|
- `border: var(--sms-border, 1pt solid lightgray)`: Border around top-level `div.multiselect`. Change this to e.g. to `1px solid red` to indicate this form field is in an invalid state.
|
|
135
130
|
- `border-radius: var(--sms-border-radius, 5pt)`: `div.multiselect` border radius.
|
|
136
131
|
- `color: var(--sms-text-color, inherit)`: Input text color.
|
|
137
|
-
- `border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue))`: `div.multiselect` border when focused.
|
|
132
|
+
- `border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue))`: `div.multiselect` border when focused. Falls back to `--sms-active-color` if not set which in turn falls back on `cornflowerblue`.
|
|
138
133
|
- `background: var(--sms-readonly-bg, lightgray)`: Background when in readonly state.
|
|
139
134
|
- `background: var(--sms-token-bg, var(--sms-active-color, cornflowerblue))`: Background of selected tokens.
|
|
140
|
-
- `color: var(--sms-remove-x-hover-color, lightgray)`: Hover color of cross icon to remove selected tokens.
|
|
135
|
+
- `color: var(--sms-remove-x-hover+focus-color, lightgray)`: Hover color of cross icon to remove selected tokens.
|
|
136
|
+
- `color: var(--sms-remove-x-hover-focus-color, lightskyblue)`: Color of the cross-icon buttons for removing all or individual selected options when in `:focus` or `:hover` state.
|
|
141
137
|
- `background: var(--sms-options-bg, white)`: Background of options list.
|
|
142
138
|
- `background: var(--sms-li-selected-bg, inherit)`: Background of selected list items in options pane.
|
|
143
139
|
- `color: var(--sms-li-selected-color, inherit)`: Text color of selected list items in options pane.
|
|
@@ -148,7 +144,7 @@ The first, if you only want to make small adjustments, allows you to pass the fo
|
|
|
148
144
|
For example, to change the background color of the options dropdown:
|
|
149
145
|
|
|
150
146
|
```svelte
|
|
151
|
-
<MultiSelect --sms-options-bg="
|
|
147
|
+
<MultiSelect --sms-options-bg="white" />
|
|
152
148
|
```
|
|
153
149
|
|
|
154
150
|
### With CSS frameworks
|
|
@@ -178,14 +174,14 @@ This simplified version of the DOM structure of this component shows where these
|
|
|
178
174
|
|
|
179
175
|
### Granular control through global CSS
|
|
180
176
|
|
|
181
|
-
You can alternatively style every part of this component with more fine-grained control by using the following `:global()` CSS selectors.
|
|
177
|
+
You can alternatively style every part of this component with more fine-grained control by using the following `:global()` CSS selectors. `ul.tokens` is the list of currently selected options rendered inside the component's input whereas `ul.options` is the list of available options that slides out when the component has focus.
|
|
182
178
|
|
|
183
179
|
```css
|
|
184
180
|
:global(.multiselect) {
|
|
185
181
|
/* top-level wrapper div */
|
|
186
182
|
}
|
|
187
183
|
:global(.multiselect ul.tokens > li) {
|
|
188
|
-
/*
|
|
184
|
+
/* selected options */
|
|
189
185
|
}
|
|
190
186
|
:global(.multiselect ul.tokens > li button),
|
|
191
187
|
:global(.multiselect button.remove-all) {
|
|
@@ -195,22 +191,37 @@ You can alternatively style every part of this component with more fine-grained
|
|
|
195
191
|
/* dropdown options */
|
|
196
192
|
}
|
|
197
193
|
:global(.multiselect ul.options li) {
|
|
198
|
-
/* dropdown options */
|
|
194
|
+
/* dropdown list of available options */
|
|
199
195
|
}
|
|
200
|
-
:global(ul.options li.selected) {
|
|
196
|
+
:global(.multiselect ul.options li.selected) {
|
|
201
197
|
/* selected options in the dropdown list */
|
|
202
198
|
}
|
|
203
|
-
:global(ul.options li:not(.selected):hover) {
|
|
199
|
+
:global(.multiselect ul.options li:not(.selected):hover) {
|
|
204
200
|
/* unselected but hovered options in the dropdown list */
|
|
205
201
|
}
|
|
206
|
-
:global(ul.options li.selected:hover) {
|
|
202
|
+
:global(.multiselect ul.options li.selected:hover) {
|
|
207
203
|
/* selected and hovered options in the dropdown list */
|
|
208
204
|
/* probably not necessary to style this state in most cases */
|
|
209
205
|
}
|
|
210
|
-
:global(ul.options li.active) {
|
|
211
|
-
/* active means
|
|
206
|
+
:global(.multiselect ul.options li.active) {
|
|
207
|
+
/* active means item was navigated to with up/down arrow keys */
|
|
212
208
|
/* ready to be selected by pressing enter */
|
|
213
209
|
}
|
|
214
|
-
:global(ul.options li.selected.active) {
|
|
210
|
+
:global(.multiselect ul.options li.selected.active) {
|
|
211
|
+
/* both active and already selected, pressing enter now will deselect the item */
|
|
215
212
|
}
|
|
213
|
+
:global(.multiselect ul.options li.disabled) {
|
|
214
|
+
/* options with disabled key set to true (see props above) */
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Want to contribute?
|
|
219
|
+
|
|
220
|
+
To submit a PR, clone the repo, install dependencies and start the dev server to try out your changes.
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
git clone https://github.com/janosh/svelte-multiselect
|
|
224
|
+
cd svelte-multiselect
|
|
225
|
+
yarn
|
|
226
|
+
yarn dev
|
|
216
227
|
```
|