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.
@@ -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 CrossIcon from './icons/Cross.svelte';
4
- import ExpandIcon from './icons/ChevronExpand.svelte';
5
- import ReadOnlyIcon from './icons/ReadOnly.svelte';
6
- export let selected;
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
- throw new TypeError(`maxSelect must be null or positive integer, got ${maxSelect}`);
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
- $: invalidDisabledOptions = disabledOptions.filter((opt) => !options.includes(opt));
30
- $: if (invalidDisabledOptions.length > 0) {
31
- console.error(`Some disabledOptions are invalid as they do not appear in the options list: ${invalidDisabledOptions}`);
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 activeOption, searchText;
63
+ let searchText = ``;
35
64
  let showOptions = false;
36
- $: filteredOptions = searchText
37
- ? options.filter((option) => option.toLowerCase().includes(searchText.toLowerCase()))
38
- : options;
39
- $: if ((activeOption && !filteredOptions.includes(activeOption)) ||
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
- activeOption = filteredOptions[0];
42
- function add(token) {
78
+ // make the first filtered option active
79
+ activeOption = matchingEnabledOptions[0];
80
+ function add(label) {
43
81
  if (!readonly &&
44
- !selected.includes(token) &&
45
- // (... || single) because in single mode, we always replace current token with new selection
46
- (maxSelect === null || selected.length < maxSelect || single)) {
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
- selected = single ? token : [token, ...selected];
49
- if ((Array.isArray(selected) && selected.length === maxSelect) ||
50
- typeof selected === `string`) {
51
- setOptionsVisible(false);
52
- input?.blur();
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(token) {
59
- if (readonly || typeof selected === `string`)
103
+ function remove(label) {
104
+ if (selected.length === 0 || readonly)
60
105
  return;
61
- selected = selected.filter((item) => item !== token);
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
- if (isDisabled(activeOption))
133
+ const { label, disabled } = activeOption;
134
+ if (disabled)
81
135
  return;
82
- selected.includes(activeOption) ? remove(activeOption) : add(activeOption);
136
+ selectedLabels.includes(label) ? remove(label) : add(label);
83
137
  searchText = ``;
84
- } // no active option means the options are closed in which case enter means open
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 = filteredOptions.indexOf(activeOption) + increment;
150
+ const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
91
151
  if (newActiveIdx < 0) {
92
- activeOption = filteredOptions[filteredOptions.length - 1];
152
+ // wrap around top
153
+ activeOption = matchingEnabledOptions[matchingEnabledOptions.length - 1];
154
+ // wrap around bottom
93
155
  }
94
- else {
95
- if (newActiveIdx === filteredOptions.length)
96
- activeOption = filteredOptions[0];
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
- // only remove selected tags on backspace if if there are any and no searchText characters remain
103
- if (selected.length > 0 && searchText.length === 0) {
104
- selected = selected.slice(0, selected.length - 1);
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 = single ? `` : [];
172
+ selected = [];
112
173
  searchText = ``;
113
174
  };
114
- const isDisabled = (option) => disabledOptions.includes(option);
115
- $: isSelected = (option) => {
116
- if (!(selected?.length > 0))
117
- return false; // nothing is selected if `selected` is the empty array or string
118
- if (single)
119
- return selected === option;
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 ture ensures the ul.tokens of one <MultiSelect /> display above those of another following shortly after it -->
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
- <ExpandIcon height="14pt" style="padding-left: 1pt;" />
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 single}
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 if selected?.length > 0}
139
- {#each selected as tag}
202
+ {:else}
203
+ {#each selected as { label }}
140
204
  <li
141
205
  class={liTokenClass}
142
- on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}>
143
- {tag}
206
+ on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
207
+ >
208
+ {label}
144
209
  {#if !readonly}
145
210
  <button
146
- on:mouseup|stopPropagation={() => remove(tag)}
211
+ on:mouseup|stopPropagation={() => remove(label)}
212
+ on:keydown={handleEnterAndSpaceKeys(() => remove(label))}
147
213
  type="button"
148
- title="{removeBtnTitle} {tag}">
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
- on:blur={() => dispatch(`blur`)}
163
- on:blur={() => setOptionsVisible(false)}
164
- placeholder={selected.length ? `` : placeholder} />
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
- style={selected.length === 0 ? `display: none;` : ``}>
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
- {#each filteredOptions as option}
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 (isDisabled(option)) return
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(option)}
193
- class:active={activeOption === option}
194
- class:disabled={isDisabled(option)}
195
- class={liOptionClass}>
196
- {option}
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
- ul.tokens > li button:hover,
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>
@@ -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: string[] | string;
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
- placeholder?: string | undefined;
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
@@ -0,0 +1,3 @@
1
+ export declare function onClickOutside(node: HTMLElement, cb?: () => void): {
2
+ destroy(): void;
3
+ };
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>
@@ -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>
@@ -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>
@@ -0,0 +1,3 @@
1
+ export { default as CrossIcon } from './Cross.svelte';
2
+ export { default as ExpandIcon } from './ChevronExpand.svelte';
3
+ export { default as ReadOnlyIcon } from './ReadOnly.svelte';
package/icons/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default as CrossIcon } from './Cross.svelte';
2
+ export { default as ExpandIcon } from './ChevronExpand.svelte';
3
+ export { default as ReadOnlyIcon } from './ReadOnly.svelte';
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": "1.2.0",
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.20",
16
- "@sveltejs/kit": "^1.0.0-next.180",
17
- "@typescript-eslint/eslint-plugin": "^5.0.0",
18
- "@typescript-eslint/parser": "^5.0.0",
19
- "eslint": "^8.0.0",
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.4.1",
24
- "prettier-plugin-svelte": "^2.4.0",
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.43.1",
28
- "svelte-check": "^2.2.7",
29
- "svelte-preprocess": "^4.9.8",
30
- "svelte-toc": "^0.1.8",
31
- "svelte2tsx": "^0.4.7",
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.3",
34
- "vite": "^2.6.7"
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 section [props](#props)
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, null, 2)}
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
- | name | default | description |
79
- | :---------------- | :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
80
- | `options` | [required] | Array of strings (or numbers) that will be listed in the dropdown selection. |
81
- | `maxSelect` | `null` | `null` or positive integer to allow users to select as many as they like or a maximum number of options, respectively. |
82
- | `selected` | `[]` (or `''` if `maxSelect === 1`) | Array of currently/pre-selected options when binding/passing as props respectively. |
83
- | `readonly` | `false` | Disables the input. User won't be able to interact with it. |
84
- | `placeholder` | `''` | String shown when no option is selected. |
85
- | `disabledOptions` | `[]` | Array of strings (or numbers) that will be disabled in the dropdown selection. |
86
- | `required` | `false` | Prevents submission in an HTML form when true. |
87
- | `input` | `undefined` | Handle to the DOM node storing the currently selected options in JSON format as its `value` attribute. |
88
- | `name` | `''` | Used as `name` reference for associating HTML form `<label>`s with this component as well as for the `<input>`'s `id`. That is, the same DOM node bindable through `<MultiSelect bind:input />` is also retrievable via `document.getElementByID(name)` e.g. for use in a JS file outside a Svelte component. |
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
- - `on:add={(event) => console.log(event.detail.token)}`
106
- - `on:remove={(event) => console.log(event.detail.token)}`.
107
- - `` on:change={(event) => console.log(`${event.detail.type}: '${event.detail.token}'`)} ``
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="var(--my-css-var, white)" />
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. **Note**: Overriding properties that the component already sets internally requires the `!important` keyword.
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
- /* the blue tags representing selected options with remove buttons inside the input */
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 element was navigated to with up/down arrow keys */
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
  ```