svelte-multiselect 11.0.0 → 11.1.1

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,13 +1,13 @@
1
- <script lang="ts">import { tick } from 'svelte';
2
- import { fade } from 'svelte/transition';
1
+ <script lang="ts">import { fade } from 'svelte/transition';
3
2
  import Select from './MultiSelect.svelte';
4
- let { actions, triggers = [`k`], close_keys = [`Escape`], fade_duration = 200, style = ``, span_style = ``, open = $bindable(false), dialog = $bindable(null), input = $bindable(null), placeholder = `Filter actions...`, children, ...rest } = $props();
3
+ let { actions, triggers = [`k`], close_keys = [`Escape`], fade_duration = 200, style = ``, open = $bindable(false), dialog = $bindable(null), input = $bindable(null), placeholder = `Filter actions...`, ...rest } = $props();
4
+ $effect(() => {
5
+ if (open && input)
6
+ input?.focus(); // focus input when palette is opened
7
+ });
5
8
  async function toggle(event) {
6
9
  if (triggers.includes(event.key) && event.metaKey && !open) {
7
- // open on cmd+trigger
8
10
  open = true;
9
- await tick(); // wait for dialog to open and input to be mounted
10
- input?.focus();
11
11
  }
12
12
  else if (close_keys.includes(event.key) && open) {
13
13
  open = false;
@@ -22,7 +22,6 @@ function trigger_action_and_close({ option }) {
22
22
  option.action(option.label);
23
23
  open = false;
24
24
  }
25
- const children_render = $derived(children);
26
25
  </script>
27
26
 
28
27
  <svelte:window onkeydown={toggle} onclick={close_if_outside} />
@@ -36,22 +35,18 @@ const children_render = $derived(children);
36
35
  onadd={trigger_action_and_close}
37
36
  onkeydown={toggle}
38
37
  {...rest}
39
- >
40
- {#snippet children({ option })}
41
- <!-- wait for https://github.com/sveltejs/svelte/pull/8304 -->
42
- {#if children_render}
43
- {@render children_render()}
44
- {:else}
45
- <span style={span_style}>{option.label}</span>
46
- {/if}
47
- {/snippet}
48
- </Select>
38
+ --sms-bg="var(--sms-options-bg)"
39
+ --sms-width="min(20em, 90vw)"
40
+ --sms-max-width="none"
41
+ --sms-placeholder-color="lightgray"
42
+ --sms-options-margin="1px 0"
43
+ --sms-options-border-radius="0 0 1ex 1ex"
44
+ />
49
45
  </dialog>
50
46
  {/if}
51
47
 
52
48
  <style>
53
- /* TODO maybe remove global */
54
- :where(:global(dialog)) {
49
+ :where(dialog) {
55
50
  position: fixed;
56
51
  top: 30%;
57
52
  border: none;
@@ -62,12 +57,4 @@ const children_render = $derived(children);
62
57
  z-index: 10;
63
58
  font-size: 2.4ex;
64
59
  }
65
- dialog :global(div.multiselect) {
66
- --sms-bg: var(--sms-options-bg);
67
- --sms-width: min(20em, 90vw);
68
- --sms-max-width: none;
69
- --sms-placeholder-color: lightgray;
70
- --sms-options-margin: 1px 0;
71
- --sms-options-border-radius: 0 0 1ex 1ex;
72
- }
73
60
  </style>
@@ -1,4 +1,3 @@
1
- import { type Snippet } from 'svelte';
2
1
  import type { MultiSelectProps } from './props';
3
2
  import type { ObjectOption } from './types';
4
3
  interface Action extends ObjectOption {
@@ -11,12 +10,10 @@ interface Props extends Omit<MultiSelectProps<Action>, `options` | `onadd` | `on
11
10
  close_keys?: string[];
12
11
  fade_duration?: number;
13
12
  style?: string;
14
- span_style?: string;
15
13
  open?: boolean;
16
14
  dialog?: HTMLDialogElement | null;
17
15
  input?: HTMLInputElement | null;
18
16
  placeholder?: string;
19
- children?: Snippet;
20
17
  }
21
18
  declare const CmdPalette: import("svelte").Component<Props, {}, "open" | "input" | "dialog">;
22
19
  type CmdPalette = ReturnType<typeof CmdPalette>;
@@ -10,7 +10,7 @@ let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptio
10
10
  return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
11
11
  }, closeDropdownOnSelect = `desktop`, form_input = $bindable(null), highlightMatches = true, id = null, input = $bindable(null), inputClass = ``, inputStyle = null, inputmode = null, invalid = $bindable(false), liActiveOptionClass = ``, liActiveUserMsgClass = ``, liOptionClass = ``, liOptionStyle = null, liSelectedClass = ``, liSelectedStyle = null, liUserMsgClass = ``, loading = false, matchingOptions = $bindable([]), maxOptions = undefined, maxSelect = null, maxSelectMsg = (current, max) => (max > 1 ? `${current}/${max}` : ``), maxSelectMsgClass = ``, name = null, noMatchingOptionsMsg = `No matching options`, open = $bindable(false), options = $bindable(), outerDiv = $bindable(null), outerDivClass = ``, parseLabelsAsHtml = false, pattern = null, placeholder = null, removeAllTitle = `Remove all`, removeBtnTitle = `Remove`, minSelect = null, required = false, resetFilterOnAdd = true, searchText = $bindable(``), selected = $bindable(options
12
12
  ?.filter((opt) => opt instanceof Object && opt?.preselected)
13
- .slice(0, maxSelect ?? undefined) ?? []), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, value = $bindable(null), expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, ...rest } = $props();
13
+ .slice(0, maxSelect ?? undefined) ?? []), sortSelected = false, selectedOptionsDraggable = !sortSelected, style = null, ulOptionsClass = ``, ulSelectedClass = ``, ulSelectedStyle = null, ulOptionsStyle = null, value = $bindable(null), expandIcon, selectedItem, children, removeIcon, afterInput, spinner, disabledIcon, option, userMsg, onblur, onclick, onfocus, onkeydown, onkeyup, onmousedown, onmouseenter, onmouseleave, ontouchcancel, ontouchend, ontouchmove, ontouchstart, onadd, oncreate, onremove, onremoveAll, onchange, onopen, onclose, portal: portal_params = {}, ...rest } = $props();
14
14
  $effect.pre(() => {
15
15
  // if maxSelect=1, value is the single item in selected (or null if selected is empty)
16
16
  // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
@@ -75,17 +75,17 @@ $effect(() => {
75
75
  activeOption = matchingOptions[activeIndex ?? -1] ?? null;
76
76
  });
77
77
  // add an option to selected list
78
- function add(option, event) {
78
+ function add(option_to_add, event) {
79
79
  event.stopPropagation();
80
- if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
80
+ if (maxSelect !== null && selected.length >= maxSelect)
81
81
  wiggle = true;
82
- if (!isNaN(Number(option)) && typeof selected.map(get_label)[0] === `number`) {
83
- option = Number(option); // convert to number if possible
82
+ if (!isNaN(Number(option_to_add)) && typeof selected.map(get_label)[0] === `number`) {
83
+ option_to_add = Number(option_to_add); // convert to number if possible
84
84
  }
85
- const is_duplicate = selected.map(key).includes(key(option));
85
+ const is_duplicate = selected.map(key).includes(key(option_to_add));
86
86
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
87
87
  (duplicates || !is_duplicate)) {
88
- if (!options.includes(option) && // first check if we find option in the options list
88
+ if (!options.includes(option_to_add) && // first check if we find option in the options list
89
89
  // this has the side-effect of not allowing to user to add the same
90
90
  // custom option twice in append mode
91
91
  [true, `append`].includes(allowUserOptions) &&
@@ -94,34 +94,34 @@ function add(option, event) {
94
94
  // a new option from the user-entered text
95
95
  if (typeof options[0] === `object`) {
96
96
  // if 1st option is an object, we create new option as object to keep type homogeneity
97
- option = { label: searchText };
97
+ option_to_add = { label: searchText };
98
98
  }
99
99
  else {
100
100
  if ([`number`, `undefined`].includes(typeof options[0]) &&
101
101
  !isNaN(Number(searchText))) {
102
102
  // create new option as number if it parses to a number and 1st option is also number or missing
103
- option = Number(searchText);
103
+ option_to_add = Number(searchText);
104
104
  }
105
105
  else {
106
- option = searchText; // else create custom option as string
106
+ option_to_add = searchText; // else create custom option as string
107
107
  }
108
- oncreate?.({ option });
108
+ oncreate?.({ option: option_to_add });
109
109
  }
110
110
  if (allowUserOptions === `append`)
111
- options = [...options, option];
111
+ options = [...options, option_to_add];
112
112
  }
113
113
  if (resetFilterOnAdd)
114
114
  searchText = ``; // reset search string on selection
115
- if ([``, undefined, null].includes(option)) {
116
- console.error(`MultiSelect: encountered falsy option ${option}`);
115
+ if ([``, undefined, null].includes(option_to_add)) {
116
+ console.error(`MultiSelect: encountered falsy option ${option_to_add}`);
117
117
  return;
118
118
  }
119
119
  if (maxSelect === 1) {
120
120
  // for maxSelect = 1 we always replace current option with new one
121
- selected = [option];
121
+ selected = [option_to_add];
122
122
  }
123
123
  else {
124
- selected = [...selected, option];
124
+ selected = [...selected, option_to_add];
125
125
  if (sortSelected === true) {
126
126
  selected = selected.sort((op1, op2) => {
127
127
  const [label1, label2] = [get_label(op1), get_label(op2)];
@@ -142,34 +142,34 @@ function add(option, event) {
142
142
  else if (!dropdown_should_close) {
143
143
  input?.focus();
144
144
  }
145
- onadd?.({ option });
146
- onchange?.({ option, type: `add` });
145
+ onadd?.({ option: option_to_add });
146
+ onchange?.({ option: option_to_add, type: `add` });
147
147
  invalid = false; // reset error status whenever new items are selected
148
148
  form_input?.setCustomValidity(``);
149
149
  }
150
150
  }
151
151
  // remove an option from selected list
152
- function remove(to_remove, event) {
152
+ function remove(option_to_drop, event) {
153
153
  event.stopPropagation();
154
154
  if (selected.length === 0)
155
155
  return;
156
- const idx = selected.findIndex((opt) => key(opt) === key(to_remove));
157
- let [option] = selected.splice(idx, 1); // remove option from selected list
158
- if (option === undefined && allowUserOptions) {
156
+ const idx = selected.findIndex((opt) => key(opt) === key(option_to_drop));
157
+ let [option_removed] = selected.splice(idx, 1); // remove option from selected list
158
+ if (option_removed === undefined && allowUserOptions) {
159
159
  // if option with label could not be found but allowUserOptions is truthy,
160
160
  // assume it was created by user and create corresponding option object
161
161
  // on the fly for use as event payload
162
162
  const other_ops_type = typeof options[0];
163
- option = (other_ops_type ? { label: to_remove } : to_remove);
163
+ option_removed = (other_ops_type ? { label: option_to_drop } : option_to_drop);
164
164
  }
165
- if (option === undefined) {
166
- return console.error(`Multiselect can't remove selected option ${JSON.stringify(to_remove)}, not found in selected list`);
165
+ if (option_removed === undefined) {
166
+ return console.error(`Multiselect can't remove selected option ${JSON.stringify(option_to_drop)}, not found in selected list`);
167
167
  }
168
168
  selected = [...selected]; // trigger Svelte rerender
169
169
  invalid = false; // reset error status whenever items are removed
170
170
  form_input?.setCustomValidity(``);
171
- onremove?.({ option });
172
- onchange?.({ option, type: `remove` });
171
+ onremove?.({ option: option_removed });
172
+ onchange?.({ option: option_removed, type: `remove` });
173
173
  }
174
174
  function open_dropdown(event) {
175
175
  event.stopPropagation();
@@ -287,7 +287,6 @@ function on_click_outside(event) {
287
287
  let drag_idx = $state(null);
288
288
  // event handlers enable dragging to reorder selected options
289
289
  const drop = (target_idx) => (event) => {
290
- event.preventDefault();
291
290
  if (!event.dataTransfer)
292
291
  return;
293
292
  event.dataTransfer.dropEffect = `move`;
@@ -339,6 +338,51 @@ $effect.pre(() => {
339
338
  required = required; // trigger effect when required changes
340
339
  form_input?.setCustomValidity(``);
341
340
  });
341
+ function portal(node, params) {
342
+ let { target_node, active } = params;
343
+ if (!active)
344
+ return;
345
+ let render_in_place = typeof window === `undefined` || !document.body.contains(node);
346
+ if (!render_in_place) {
347
+ document.body.appendChild(node);
348
+ node.style.position = `fixed`;
349
+ const update_position = () => {
350
+ if (!target_node || !open)
351
+ return (node.hidden = true);
352
+ const rect = target_node.getBoundingClientRect();
353
+ node.style.left = `${rect.left}px`;
354
+ node.style.top = `${rect.bottom}px`;
355
+ node.style.width = `${rect.width}px`;
356
+ node.hidden = false;
357
+ };
358
+ if (open)
359
+ tick().then(update_position);
360
+ window.addEventListener(`scroll`, update_position, true);
361
+ window.addEventListener(`resize`, update_position);
362
+ $effect.pre(() => {
363
+ if (open && target_node)
364
+ update_position();
365
+ else
366
+ node.hidden = true;
367
+ });
368
+ return {
369
+ update(params) {
370
+ target_node = params.target_node;
371
+ render_in_place = typeof window === `undefined` || !document.body.contains(node);
372
+ if (open && !render_in_place && target_node)
373
+ tick().then(update_position);
374
+ else if (!open || !target_node)
375
+ node.hidden = true;
376
+ },
377
+ destroy() {
378
+ if (!render_in_place)
379
+ node.remove();
380
+ window.removeEventListener(`scroll`, update_position, true);
381
+ window.removeEventListener(`resize`, update_position);
382
+ },
383
+ };
384
+ }
385
+ }
342
386
  </script>
343
387
 
344
388
  <svelte:window
@@ -353,7 +397,7 @@ $effect.pre(() => {
353
397
  class:single={maxSelect === 1}
354
398
  class:open
355
399
  class:invalid
356
- class="multiselect {outerDivClass}"
400
+ class="multiselect {outerDivClass} {rest.class ?? ``}"
357
401
  onmouseup={open_dropdown}
358
402
  title={disabled ? disabledInputTitle : null}
359
403
  data-id={id}
@@ -406,6 +450,9 @@ $effect.pre(() => {
406
450
  animate:flip={{ duration: 100 }}
407
451
  draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
408
452
  ondragstart={dragstart(idx)}
453
+ ondragover={(event) => {
454
+ event.preventDefault() // needed for ondrop to fire
455
+ }}
409
456
  ondrop={drop(idx)}
410
457
  ondragenter={() => (drag_idx = idx)}
411
458
  class:active={drag_idx === idx}
@@ -524,6 +571,7 @@ $effect.pre(() => {
524
571
  <!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
525
572
  {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
526
573
  <ul
574
+ use:portal={{ target_node: outerDiv, ...portal_params }}
527
575
  class:hidden={!open}
528
576
  class="options {ulOptionsClass}"
529
577
  role="listbox"
@@ -760,14 +808,19 @@ $effect.pre(() => {
760
808
  pointer-events: none;
761
809
  }
762
810
 
763
- :is(div.multiselect > ul.options) {
811
+ ul.options {
764
812
  list-style: none;
813
+ /* top, left, width, position are managed by portal when active */
814
+ /* but provide defaults for non-portaled or initial state */
815
+ position: absolute; /* Default, overridden by portal to fixed when open */
765
816
  top: 100%;
766
817
  left: 0;
767
818
  width: 100%;
768
- position: absolute;
819
+ /* Default z-index if not portaled/overridden by portal */
820
+ z-index: var(--sms-options-z-index, 3);
821
+
769
822
  overflow: auto;
770
- transition: all 0.2s;
823
+ transition: all 0.2s; /* Consider if this transition is desirable with portal positioning */
771
824
  box-sizing: border-box;
772
825
  background: var(--sms-options-bg, white);
773
826
  max-height: var(--sms-options-max-height, 50vh);
@@ -779,29 +832,35 @@ $effect.pre(() => {
779
832
  padding: var(--sms-options-padding);
780
833
  margin: var(--sms-options-margin, inherit);
781
834
  }
782
- :is(div.multiselect > ul.options.hidden) {
835
+ :is(div.multiselect.open) {
836
+ /* increase z-index when open to ensure the dropdown of one <MultiSelect />
837
+ displays above that of another slightly below it on the page */
838
+ /* This z-index is for the div.multiselect itself, portal has its own higher z-index */
839
+ z-index: var(--sms-open-z-index, 4);
840
+ }
841
+ ul.options.hidden {
783
842
  visibility: hidden;
784
843
  opacity: 0;
785
844
  transform: translateY(50px);
786
845
  }
787
- :is(div.multiselect > ul.options > li) {
846
+ ul.options > li {
788
847
  padding: 3pt 2ex;
789
848
  cursor: pointer;
790
849
  scroll-margin: var(--sms-options-scroll-margin, 100px);
791
850
  }
792
- :is(div.multiselect > ul.options .user-msg) {
851
+ ul.options .user-msg {
793
852
  /* block needed so vertical padding applies to span */
794
853
  display: block;
795
854
  padding: 3pt 2ex;
796
855
  }
797
- :is(div.multiselect > ul.options > li.selected) {
856
+ ul.options > li.selected {
798
857
  background: var(--sms-li-selected-bg);
799
858
  color: var(--sms-li-selected-color);
800
859
  }
801
- :is(div.multiselect > ul.options > li.active) {
860
+ ul.options > li.active {
802
861
  background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
803
862
  }
804
- :is(div.multiselect > ul.options > li.disabled) {
863
+ ul.options > li.disabled {
805
864
  cursor: not-allowed;
806
865
  background: var(--sms-li-disabled-bg, #f5f5f6);
807
866
  color: var(--sms-li-disabled-text, #b8b8b8);
package/dist/props.d.ts CHANGED
@@ -70,6 +70,10 @@ export interface MultiSelectSnippets<T extends Option = Option> {
70
70
  }
71
71
  ]>;
72
72
  }
73
+ export interface PortalParams {
74
+ target_node?: HTMLElement | null;
75
+ active?: boolean;
76
+ }
73
77
  export interface MultiSelectParameters<T extends Option = Option> {
74
78
  activeIndex?: number | null;
75
79
  activeOption?: T | null;
@@ -132,6 +136,7 @@ export interface MultiSelectParameters<T extends Option = Option> {
132
136
  ulSelectedStyle?: string | null;
133
137
  ulOptionsStyle?: string | null;
134
138
  value?: T | T[] | null;
139
+ portal?: PortalParams;
135
140
  [key: string]: unknown;
136
141
  }
137
142
  export interface MultiSelectProps<T extends Option = Option> extends MultiSelectNativeEvents, MultiSelectComponentEvents<T>, MultiSelectSnippets<T>, MultiSelectParameters<T> {
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "homepage": "https://janosh.github.io/svelte-multiselect",
6
6
  "repository": "https://github.com/janosh/svelte-multiselect",
7
7
  "license": "MIT",
8
- "version": "11.0.0",
8
+ "version": "11.1.1",
9
9
  "type": "module",
10
10
  "svelte": "./dist/index.js",
11
11
  "bugs": "https://github.com/janosh/svelte-multiselect/issues",
@@ -16,7 +16,7 @@
16
16
  "package": "svelte-package",
17
17
  "serve": "vite build && vite preview",
18
18
  "check": "svelte-check src",
19
- "test": "vitest --run --coverage tests/unit/*.ts && npm run test:e2e",
19
+ "test": "vitest run && playwright test",
20
20
  "test:unit": "vitest tests/unit/*.ts",
21
21
  "test:e2e": "playwright test tests/*.test.ts",
22
22
  "changelog": "npx auto-changelog --package --output changelog.md --hide-empty-releases --hide-credit --commit-limit false",
@@ -30,13 +30,13 @@
30
30
  "@playwright/test": "^1.52.0",
31
31
  "@stylistic/eslint-plugin": "^4.2.0",
32
32
  "@sveltejs/adapter-static": "^3.0.8",
33
- "@sveltejs/kit": "^2.21.0",
33
+ "@sveltejs/kit": "^2.21.1",
34
34
  "@sveltejs/package": "2.3.11",
35
35
  "@sveltejs/vite-plugin-svelte": "^5.0.3",
36
- "@types/node": "^22.15.18",
37
- "@vitest/coverage-v8": "^3.1.3",
38
- "eslint": "^9.26.0",
39
- "eslint-plugin-svelte": "^3.6.0",
36
+ "@types/node": "^22.15.20",
37
+ "@vitest/coverage-v8": "^3.1.4",
38
+ "eslint": "^9.27.0",
39
+ "eslint-plugin-svelte": "^3.8.2",
40
40
  "globals": "^16.1.0",
41
41
  "hastscript": "^9.0.1",
42
42
  "highlight.js": "^11.11.1",
@@ -47,17 +47,16 @@
47
47
  "prettier-plugin-svelte": "^3.4.0",
48
48
  "rehype-autolink-headings": "^7.1.0",
49
49
  "rehype-slug": "^6.0.0",
50
- "svelte": "^5.30.1",
50
+ "svelte": "^5.32.1",
51
51
  "svelte-check": "^4.2.1",
52
- "svelte-multiselect": "11.0.0-rc.1",
53
52
  "svelte-preprocess": "^6.0.3",
54
- "svelte-toc": "^0.6.0",
53
+ "svelte-toc": "^0.6.1",
55
54
  "svelte-zoo": "^0.4.18",
56
55
  "svelte2tsx": "^0.7.39",
57
56
  "typescript": "5.8.3",
58
57
  "typescript-eslint": "^8.32.1",
59
58
  "vite": "^6.3.5",
60
- "vitest": "^3.1.3"
59
+ "vitest": "^3.1.4"
61
60
  },
62
61
  "keywords": [
63
62
  "svelte",
package/readme.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![Tests](https://github.com/janosh/svelte-multiselect/actions/workflows/test.yml/badge.svg)](https://github.com/janosh/svelte-multiselect/actions/workflows/test.yml)
9
9
  [![GitHub Pages](https://github.com/janosh/svelte-multiselect/actions/workflows/gh-pages.yml/badge.svg)](https://github.com/janosh/svelte-multiselect/actions/workflows/gh-pages.yml)
10
10
  [![NPM version](https://img.shields.io/npm/v/svelte-multiselect?logo=NPM&color=purple)](https://npmjs.com/package/svelte-multiselect)
11
- [![Needs Svelte version](https://img.shields.io/npm/dependency-version/svelte-multiselect/svelte?color=teal&logo=Svelte&label=Svelte)](https://github.com/sveltejs/svelte/blob/master/packages/svelte/CHANGELOG.md)
11
+ [![Needs Svelte version](https://img.shields.io/npm/dependency-version/svelte-multiselect/peer/svelte?color=teal&logo=Svelte&label=Svelte)](https://github.com/sveltejs/svelte/blob/master/packages/svelte/CHANGELOG.md)
12
12
  [![REPL](https://img.shields.io/badge/Svelte-REPL-blue?label=Try%20it!)](https://svelte.dev/repl/a5a14b8f15d64cb083b567292480db05)
13
13
  [![Open in StackBlitz](https://img.shields.io/badge/Open%20in-StackBlitz-darkblue?logo=stackblitz)](https://stackblitz.com/github/janosh/svelte-multiselect)
14
14