svelte-multiselect 11.0.0 → 11.1.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,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>;
@@ -1,4 +1,5 @@
1
- <script lang="ts">import { tick } from 'svelte';
1
+ <script lang="ts">import { browser } from '$app/environment';
2
+ import { tick } from 'svelte';
2
3
  import { flip } from 'svelte/animate';
3
4
  import CircleSpinner from './CircleSpinner.svelte';
4
5
  import Wiggle from './Wiggle.svelte';
@@ -10,7 +11,7 @@ let { activeIndex = $bindable(null), activeOption = $bindable(null), createOptio
10
11
  return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase());
11
12
  }, 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
13
  ?.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();
14
+ .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
15
  $effect.pre(() => {
15
16
  // if maxSelect=1, value is the single item in selected (or null if selected is empty)
16
17
  // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
@@ -75,17 +76,17 @@ $effect(() => {
75
76
  activeOption = matchingOptions[activeIndex ?? -1] ?? null;
76
77
  });
77
78
  // add an option to selected list
78
- function add(option, event) {
79
+ function add(option_to_add, event) {
79
80
  event.stopPropagation();
80
- if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
81
+ if (maxSelect !== null && selected.length >= maxSelect)
81
82
  wiggle = true;
82
- if (!isNaN(Number(option)) && typeof selected.map(get_label)[0] === `number`) {
83
- option = Number(option); // convert to number if possible
83
+ if (!isNaN(Number(option_to_add)) && typeof selected.map(get_label)[0] === `number`) {
84
+ option_to_add = Number(option_to_add); // convert to number if possible
84
85
  }
85
- const is_duplicate = selected.map(key).includes(key(option));
86
+ const is_duplicate = selected.map(key).includes(key(option_to_add));
86
87
  if ((maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
87
88
  (duplicates || !is_duplicate)) {
88
- if (!options.includes(option) && // first check if we find option in the options list
89
+ if (!options.includes(option_to_add) && // first check if we find option in the options list
89
90
  // this has the side-effect of not allowing to user to add the same
90
91
  // custom option twice in append mode
91
92
  [true, `append`].includes(allowUserOptions) &&
@@ -94,34 +95,34 @@ function add(option, event) {
94
95
  // a new option from the user-entered text
95
96
  if (typeof options[0] === `object`) {
96
97
  // if 1st option is an object, we create new option as object to keep type homogeneity
97
- option = { label: searchText };
98
+ option_to_add = { label: searchText };
98
99
  }
99
100
  else {
100
101
  if ([`number`, `undefined`].includes(typeof options[0]) &&
101
102
  !isNaN(Number(searchText))) {
102
103
  // create new option as number if it parses to a number and 1st option is also number or missing
103
- option = Number(searchText);
104
+ option_to_add = Number(searchText);
104
105
  }
105
106
  else {
106
- option = searchText; // else create custom option as string
107
+ option_to_add = searchText; // else create custom option as string
107
108
  }
108
- oncreate?.({ option });
109
+ oncreate?.({ option: option_to_add });
109
110
  }
110
111
  if (allowUserOptions === `append`)
111
- options = [...options, option];
112
+ options = [...options, option_to_add];
112
113
  }
113
114
  if (resetFilterOnAdd)
114
115
  searchText = ``; // reset search string on selection
115
- if ([``, undefined, null].includes(option)) {
116
- console.error(`MultiSelect: encountered falsy option ${option}`);
116
+ if ([``, undefined, null].includes(option_to_add)) {
117
+ console.error(`MultiSelect: encountered falsy option ${option_to_add}`);
117
118
  return;
118
119
  }
119
120
  if (maxSelect === 1) {
120
121
  // for maxSelect = 1 we always replace current option with new one
121
- selected = [option];
122
+ selected = [option_to_add];
122
123
  }
123
124
  else {
124
- selected = [...selected, option];
125
+ selected = [...selected, option_to_add];
125
126
  if (sortSelected === true) {
126
127
  selected = selected.sort((op1, op2) => {
127
128
  const [label1, label2] = [get_label(op1), get_label(op2)];
@@ -142,34 +143,34 @@ function add(option, event) {
142
143
  else if (!dropdown_should_close) {
143
144
  input?.focus();
144
145
  }
145
- onadd?.({ option });
146
- onchange?.({ option, type: `add` });
146
+ onadd?.({ option: option_to_add });
147
+ onchange?.({ option: option_to_add, type: `add` });
147
148
  invalid = false; // reset error status whenever new items are selected
148
149
  form_input?.setCustomValidity(``);
149
150
  }
150
151
  }
151
152
  // remove an option from selected list
152
- function remove(to_remove, event) {
153
+ function remove(option_to_drop, event) {
153
154
  event.stopPropagation();
154
155
  if (selected.length === 0)
155
156
  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) {
157
+ const idx = selected.findIndex((opt) => key(opt) === key(option_to_drop));
158
+ let [option_removed] = selected.splice(idx, 1); // remove option from selected list
159
+ if (option_removed === undefined && allowUserOptions) {
159
160
  // if option with label could not be found but allowUserOptions is truthy,
160
161
  // assume it was created by user and create corresponding option object
161
162
  // on the fly for use as event payload
162
163
  const other_ops_type = typeof options[0];
163
- option = (other_ops_type ? { label: to_remove } : to_remove);
164
+ option_removed = (other_ops_type ? { label: option_to_drop } : option_to_drop);
164
165
  }
165
- if (option === undefined) {
166
- return console.error(`Multiselect can't remove selected option ${JSON.stringify(to_remove)}, not found in selected list`);
166
+ if (option_removed === undefined) {
167
+ return console.error(`Multiselect can't remove selected option ${JSON.stringify(option_to_drop)}, not found in selected list`);
167
168
  }
168
169
  selected = [...selected]; // trigger Svelte rerender
169
170
  invalid = false; // reset error status whenever items are removed
170
171
  form_input?.setCustomValidity(``);
171
- onremove?.({ option });
172
- onchange?.({ option, type: `remove` });
172
+ onremove?.({ option: option_removed });
173
+ onchange?.({ option: option_removed, type: `remove` });
173
174
  }
174
175
  function open_dropdown(event) {
175
176
  event.stopPropagation();
@@ -287,7 +288,6 @@ function on_click_outside(event) {
287
288
  let drag_idx = $state(null);
288
289
  // event handlers enable dragging to reorder selected options
289
290
  const drop = (target_idx) => (event) => {
290
- event.preventDefault();
291
291
  if (!event.dataTransfer)
292
292
  return;
293
293
  event.dataTransfer.dropEffect = `move`;
@@ -339,6 +339,51 @@ $effect.pre(() => {
339
339
  required = required; // trigger effect when required changes
340
340
  form_input?.setCustomValidity(``);
341
341
  });
342
+ function portal(node, params) {
343
+ let { target_node, active } = params;
344
+ if (!active)
345
+ return;
346
+ let render_in_place = !browser || !document.body.contains(node);
347
+ if (!render_in_place) {
348
+ document.body.appendChild(node);
349
+ node.style.position = `fixed`;
350
+ const update_position = () => {
351
+ if (!target_node || !open)
352
+ return (node.hidden = true);
353
+ const rect = target_node.getBoundingClientRect();
354
+ node.style.left = `${rect.left}px`;
355
+ node.style.top = `${rect.bottom}px`;
356
+ node.style.width = `${rect.width}px`;
357
+ node.hidden = false;
358
+ };
359
+ if (open)
360
+ tick().then(update_position);
361
+ window.addEventListener(`scroll`, update_position, true);
362
+ window.addEventListener(`resize`, update_position);
363
+ $effect.pre(() => {
364
+ if (open && target_node)
365
+ update_position();
366
+ else
367
+ node.hidden = true;
368
+ });
369
+ return {
370
+ update(params) {
371
+ target_node = params.target_node;
372
+ render_in_place = !browser || !document.body.contains(node);
373
+ if (open && !render_in_place && target_node)
374
+ tick().then(update_position);
375
+ else if (!open || !target_node)
376
+ node.hidden = true;
377
+ },
378
+ destroy() {
379
+ if (!render_in_place)
380
+ node.remove();
381
+ window.removeEventListener(`scroll`, update_position, true);
382
+ window.removeEventListener(`resize`, update_position);
383
+ },
384
+ };
385
+ }
386
+ }
342
387
  </script>
343
388
 
344
389
  <svelte:window
@@ -353,7 +398,7 @@ $effect.pre(() => {
353
398
  class:single={maxSelect === 1}
354
399
  class:open
355
400
  class:invalid
356
- class="multiselect {outerDivClass}"
401
+ class="multiselect {outerDivClass} {rest.class ?? ``}"
357
402
  onmouseup={open_dropdown}
358
403
  title={disabled ? disabledInputTitle : null}
359
404
  data-id={id}
@@ -406,6 +451,9 @@ $effect.pre(() => {
406
451
  animate:flip={{ duration: 100 }}
407
452
  draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
408
453
  ondragstart={dragstart(idx)}
454
+ ondragover={(event) => {
455
+ event.preventDefault() // needed for ondrop to fire
456
+ }}
409
457
  ondrop={drop(idx)}
410
458
  ondragenter={() => (drag_idx = idx)}
411
459
  class:active={drag_idx === idx}
@@ -524,6 +572,7 @@ $effect.pre(() => {
524
572
  <!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
525
573
  {#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
526
574
  <ul
575
+ use:portal={{ target_node: outerDiv, ...portal_params }}
527
576
  class:hidden={!open}
528
577
  class="options {ulOptionsClass}"
529
578
  role="listbox"
@@ -760,14 +809,19 @@ $effect.pre(() => {
760
809
  pointer-events: none;
761
810
  }
762
811
 
763
- :is(div.multiselect > ul.options) {
812
+ ul.options {
764
813
  list-style: none;
814
+ /* top, left, width, position are managed by portal when active */
815
+ /* but provide defaults for non-portaled or initial state */
816
+ position: absolute; /* Default, overridden by portal to fixed when open */
765
817
  top: 100%;
766
818
  left: 0;
767
819
  width: 100%;
768
- position: absolute;
820
+ /* Default z-index if not portaled/overridden by portal */
821
+ z-index: var(--sms-options-z-index, 3);
822
+
769
823
  overflow: auto;
770
- transition: all 0.2s;
824
+ transition: all 0.2s; /* Consider if this transition is desirable with portal positioning */
771
825
  box-sizing: border-box;
772
826
  background: var(--sms-options-bg, white);
773
827
  max-height: var(--sms-options-max-height, 50vh);
@@ -779,29 +833,35 @@ $effect.pre(() => {
779
833
  padding: var(--sms-options-padding);
780
834
  margin: var(--sms-options-margin, inherit);
781
835
  }
782
- :is(div.multiselect > ul.options.hidden) {
836
+ :is(div.multiselect.open) {
837
+ /* increase z-index when open to ensure the dropdown of one <MultiSelect />
838
+ displays above that of another slightly below it on the page */
839
+ /* This z-index is for the div.multiselect itself, portal has its own higher z-index */
840
+ z-index: var(--sms-open-z-index, 4);
841
+ }
842
+ ul.options.hidden {
783
843
  visibility: hidden;
784
844
  opacity: 0;
785
845
  transform: translateY(50px);
786
846
  }
787
- :is(div.multiselect > ul.options > li) {
847
+ ul.options > li {
788
848
  padding: 3pt 2ex;
789
849
  cursor: pointer;
790
850
  scroll-margin: var(--sms-options-scroll-margin, 100px);
791
851
  }
792
- :is(div.multiselect > ul.options .user-msg) {
852
+ ul.options .user-msg {
793
853
  /* block needed so vertical padding applies to span */
794
854
  display: block;
795
855
  padding: 3pt 2ex;
796
856
  }
797
- :is(div.multiselect > ul.options > li.selected) {
857
+ ul.options > li.selected {
798
858
  background: var(--sms-li-selected-bg);
799
859
  color: var(--sms-li-selected-color);
800
860
  }
801
- :is(div.multiselect > ul.options > li.active) {
861
+ ul.options > li.active {
802
862
  background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
803
863
  }
804
- :is(div.multiselect > ul.options > li.disabled) {
864
+ ul.options > li.disabled {
805
865
  cursor: not-allowed;
806
866
  background: var(--sms-li-disabled-bg, #f5f5f6);
807
867
  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.0",
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",
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