svelte-multiselect 3.3.0 → 4.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.
@@ -0,0 +1,29 @@
1
+ <script >export let color = `cornflowerblue`;
2
+ export let duration = `1.5s`;
3
+ export let size = `1em`;
4
+ </script>
5
+
6
+ <div
7
+ style="--duration: {duration}"
8
+ style:border-color="{color} transparent {color}
9
+ {color}"
10
+ style:width={size}
11
+ style:height={size}
12
+ />
13
+
14
+ <style>
15
+ div {
16
+ display: inline-block;
17
+ vertical-align: middle;
18
+ margin: 0 3pt;
19
+ border-width: calc(1em / 5);
20
+ border-style: solid;
21
+ border-radius: 50%;
22
+ animation: var(--duration) infinite rotate;
23
+ }
24
+ @keyframes rotate {
25
+ 100% {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+ </style>
@@ -0,0 +1,18 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ declare const __propDef: {
3
+ props: {
4
+ color?: string | undefined;
5
+ duration?: string | undefined;
6
+ size?: string | undefined;
7
+ };
8
+ events: {
9
+ [evt: string]: CustomEvent<any>;
10
+ };
11
+ slots: {};
12
+ };
13
+ export declare type CircleSpinnerProps = typeof __propDef.props;
14
+ export declare type CircleSpinnerEvents = typeof __propDef.events;
15
+ export declare type CircleSpinnerSlots = typeof __propDef.slots;
16
+ export default class CircleSpinner extends SvelteComponentTyped<CircleSpinnerProps, CircleSpinnerEvents, CircleSpinnerSlots> {
17
+ }
18
+ export {};
@@ -1,6 +1,7 @@
1
- <script >import { createEventDispatcher, onMount } from 'svelte';
1
+ <script >import { createEventDispatcher, onMount, tick } from 'svelte';
2
2
  import { fly } from 'svelte/transition';
3
3
  import { onClickOutside } from './actions';
4
+ import CircleSpinner from './CircleSpinner.svelte';
4
5
  import { CrossIcon, ExpandIcon, ReadOnlyIcon } from './icons';
5
6
  import Wiggle from './Wiggle.svelte';
6
7
  export let selected = [];
@@ -33,6 +34,10 @@ export let removeBtnTitle = `Remove`;
33
34
  export let removeAllTitle = `Remove all`;
34
35
  // https://github.com/sveltejs/svelte/issues/6964
35
36
  export let defaultDisabledTitle = `This option is disabled`;
37
+ export let allowUserOptions = false;
38
+ export let autoScroll = true;
39
+ export let loading = false;
40
+ export let required = false;
36
41
  if (maxSelect !== null && maxSelect < 0) {
37
42
  console.error(`maxSelect must be null or positive integer, got ${maxSelect}`);
38
43
  }
@@ -44,6 +49,9 @@ onMount(() => {
44
49
  selected = _options.filter((op) => op?.preselected);
45
50
  });
46
51
  let wiggle = false;
52
+ // formValue binds to input.form-control to prevent form submission if required
53
+ // prop is true and no options are selected
54
+ $: formValue = selectedValues.join(`,`);
47
55
  const dispatch = createEventDispatcher();
48
56
  function isObject(item) {
49
57
  return typeof item === `object` && !Array.isArray(item) && item !== null;
@@ -72,16 +80,8 @@ $: if (new Set(labels).size !== options.length) {
72
80
  $: selectedLabels = selected.map((op) => op.label);
73
81
  $: selectedValues = selected.map((op) => op.value);
74
82
  // options matching the current search text
75
- $: matchingOptions = _options.filter((op) => filterFunc(op, searchText));
83
+ $: matchingOptions = _options.filter((op) => filterFunc(op, searchText) && !selectedLabels.includes(op.label));
76
84
  $: matchingEnabledOptions = matchingOptions.filter((op) => !op.disabled);
77
- $: if (
78
- // if there was an active option but it's not in the filtered list of options
79
- (activeOption &&
80
- !matchingEnabledOptions.map((op) => op.label).includes(activeOption.label)) ||
81
- // or there's no active option but the user entered search text
82
- (!activeOption && searchText))
83
- // make the first filtered option active
84
- activeOption = matchingEnabledOptions[0];
85
85
  function add(label) {
86
86
  if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
87
87
  wiggle = true;
@@ -110,8 +110,11 @@ function add(label) {
110
110
  function remove(label) {
111
111
  if (selected.length === 0 || readonly)
112
112
  return;
113
- selected = selected.filter((option) => label !== option.label);
114
113
  const option = _options.find((option) => option.label === label);
114
+ if (!option) {
115
+ return console.error(`MultiSelect: option with label ${label} not found`);
116
+ }
117
+ selected = selected.filter((option) => label !== option.label);
115
118
  dispatch(`remove`, { option });
116
119
  dispatch(`change`, { option, type: `remove` });
117
120
  }
@@ -125,7 +128,7 @@ function setOptionsVisible(show) {
125
128
  }
126
129
  }
127
130
  // handle all keyboard events this component receives
128
- function handleKeydown(event) {
131
+ async function handleKeydown(event) {
129
132
  // on escape: dismiss options dropdown and reset search text
130
133
  if (event.key === `Escape`) {
131
134
  setOptionsVisible(false);
@@ -137,7 +140,15 @@ function handleKeydown(event) {
137
140
  const { label } = activeOption;
138
141
  selectedLabels.includes(label) ? remove(label) : add(label);
139
142
  searchText = ``;
140
- } // no active option means the options dropdown is closed in which case enter means open it
143
+ }
144
+ else if ([true, `append`].includes(allowUserOptions)) {
145
+ selected = [...selected, { label: searchText, value: searchText }];
146
+ if (allowUserOptions === `append`)
147
+ options = [...options, { label: searchText, value: searchText }];
148
+ searchText = ``;
149
+ }
150
+ // no active option and no search text means the options dropdown is closed
151
+ // in which case enter means open it
141
152
  else
142
153
  setOptionsVisible(true);
143
154
  }
@@ -150,31 +161,25 @@ function handleKeydown(event) {
150
161
  }
151
162
  const increment = event.key === `ArrowUp` ? -1 : 1;
152
163
  const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
153
- const ulOps = document.querySelector(`ul.options`);
154
164
  if (newActiveIdx < 0) {
155
165
  // wrap around top
156
166
  activeOption = matchingEnabledOptions[matchingEnabledOptions.length - 1];
157
- if (ulOps)
158
- ulOps.scrollTop = ulOps.scrollHeight;
159
167
  }
160
168
  else if (newActiveIdx === matchingEnabledOptions.length) {
161
169
  // wrap around bottom
162
170
  activeOption = matchingEnabledOptions[0];
163
- if (ulOps)
164
- ulOps.scrollTop = 0;
165
171
  }
166
172
  else {
167
- // default case
173
+ // default case: select next/previous in item list
168
174
  activeOption = matchingEnabledOptions[newActiveIdx];
175
+ }
176
+ if (autoScroll) {
177
+ await tick();
169
178
  const li = document.querySelector(`ul.options > li.active`);
170
- // scrollIntoViewIfNeeded() scrolls top edge of element into view so when moving
171
- // downwards, we scroll to next sibling to make element fully visible
172
- if (increment === 1)
173
- li?.nextSibling?.scrollIntoViewIfNeeded();
174
- else
175
- li?.scrollIntoViewIfNeeded();
179
+ li?.scrollIntoViewIfNeeded();
176
180
  }
177
181
  }
182
+ // on backspace key: remove last selected option
178
183
  else if (event.key === `Backspace`) {
179
184
  const label = selectedLabels.pop();
180
185
  if (label && !searchText)
@@ -207,11 +212,13 @@ display above those of another following shortly after it -->
207
212
  use:onClickOutside={() => setOptionsVisible(false)}
208
213
  use:onClickOutside={() => dispatch(`blur`)}
209
214
  >
215
+ <!-- invisible input, used only to prevent form submission if required=true and no options selected -->
216
+ <input {required} bind:value={formValue} tabindex="-1" class="form-control" />
210
217
  <ExpandIcon style="min-width: 1em; padding: 0 1pt;" />
211
218
  <ul class="selected {ulSelectedClass}">
212
219
  {#each selected as option, idx}
213
220
  <li class={liSelectedClass}>
214
- <slot name="renderSelected" {option} {idx}>
221
+ <slot name="selected" {option} {idx}>
215
222
  {option.label}
216
223
  </slot>
217
224
  {#if !readonly}
@@ -240,6 +247,11 @@ display above those of another following shortly after it -->
240
247
  />
241
248
  </li>
242
249
  </ul>
250
+ {#if loading}
251
+ <slot name="spinner">
252
+ <CircleSpinner />
253
+ </slot>
254
+ {/if}
243
255
  {#if readonly}
244
256
  <ReadOnlyIcon height="14pt" />
245
257
  {:else if selected.length > 0}
@@ -286,7 +298,7 @@ display above those of another following shortly after it -->
286
298
  class:disabled
287
299
  class="{liOptionClass} {active ? liActiveOptionClass : ``}"
288
300
  >
289
- <slot name="renderOptions" {option} {idx}>
301
+ <slot name="option" {option} {idx}>
290
302
  {option.label}
291
303
  </slot>
292
304
  </li>
@@ -372,10 +384,18 @@ display above those of another following shortly after it -->
372
384
  font-size: calc(16px + 0.1vw);
373
385
  color: var(--sms-text-color, inherit);
374
386
  }
387
+ :where(div.multiselect > input.form-control) {
388
+ width: 2em;
389
+ position: absolute;
390
+ background: transparent;
391
+ border: none;
392
+ outline: none;
393
+ z-index: -1;
394
+ opacity: 0;
395
+ }
375
396
 
376
397
  :where(div.multiselect > ul.options) {
377
398
  list-style: none;
378
- max-height: 50vh;
379
399
  padding: 0;
380
400
  top: 100%;
381
401
  left: 0;
@@ -384,6 +404,7 @@ display above those of another following shortly after it -->
384
404
  border-radius: 1ex;
385
405
  overflow: auto;
386
406
  background: var(--sms-options-bg, white);
407
+ max-height: var(--sms-options-max-height, 50vh);
387
408
  overscroll-behavior: var(--sms-options-overscroll, none);
388
409
  box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);
389
410
  }
@@ -393,6 +414,7 @@ display above those of another following shortly after it -->
393
414
  :where(div.multiselect > ul.options > li) {
394
415
  padding: 3pt 2ex;
395
416
  cursor: pointer;
417
+ scroll-margin: var(--sms-options-scroll-margin, 100px);
396
418
  }
397
419
  /* for noOptionsMsg */
398
420
  :where(div.multiselect > ul.options span) {
@@ -27,6 +27,10 @@ declare const __propDef: {
27
27
  removeBtnTitle?: string | undefined;
28
28
  removeAllTitle?: string | undefined;
29
29
  defaultDisabledTitle?: string | undefined;
30
+ allowUserOptions?: boolean | "append" | undefined;
31
+ autoScroll?: boolean | undefined;
32
+ loading?: boolean | undefined;
33
+ required?: boolean | undefined;
30
34
  };
31
35
  events: {
32
36
  mouseup: MouseEvent;
@@ -34,11 +38,12 @@ declare const __propDef: {
34
38
  [evt: string]: CustomEvent<any>;
35
39
  };
36
40
  slots: {
37
- renderSelected: {
41
+ selected: {
38
42
  option: Option;
39
43
  idx: any;
40
44
  };
41
- renderOptions: {
45
+ spinner: {};
46
+ option: {
42
47
  option: Option;
43
48
  idx: any;
44
49
  };
package/index.d.ts CHANGED
@@ -13,3 +13,20 @@ export declare type Option = {
13
13
  export declare type ProtoOption = Primitive | (Omit<Option, `value`> & {
14
14
  value?: Primitive;
15
15
  });
16
+ export declare type DispatchEvents = {
17
+ add: {
18
+ option: Option;
19
+ };
20
+ remove: {
21
+ option: Option;
22
+ };
23
+ removeAll: {
24
+ options: Option[];
25
+ };
26
+ change: {
27
+ option?: Option;
28
+ options?: Option[];
29
+ type: 'add' | 'remove' | 'removeAll';
30
+ };
31
+ blur: undefined;
32
+ };
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "homepage": "https://svelte-multiselect.netlify.app",
6
6
  "repository": "https://github.com/janosh/svelte-multiselect",
7
7
  "license": "MIT",
8
- "version": "3.3.0",
8
+ "version": "4.0.0",
9
9
  "type": "module",
10
10
  "svelte": "index.js",
11
11
  "bugs": "https://github.com/janosh/svelte-multiselect/issues",
package/readme.md CHANGED
@@ -35,7 +35,6 @@
35
35
 
36
36
  ## Recent breaking changes
37
37
 
38
- - v2.0.0 added the ability to pass options as objects. As a result, `bind:selected` no longer returns simple strings but objects, even if you still pass in `options` as strings. To get the same stuff you would have gotten from `bind:selected` before, there's now `bind:selectedLabels` (and `bind:selectedValues`).
39
38
  - v3.0.0 changed the `event.detail` payload for `'add'`, `'remove'` and `'change'` events from `token` to `option`, e.g.
40
39
 
41
40
  ```js
@@ -45,6 +44,10 @@
45
44
 
46
45
  It also added a separate event type `removeAll` for when the user removes all currently selected options at once which previously fired a normal `remove`. The props `ulTokensClass` and `liTokenClass` were renamed to `ulSelectedClass` and `liSelectedClass`. Similarly, the CSS variable `--sms-token-bg` changed to `--sms-selected-bg`.
47
46
 
47
+ - v4.0.0 renamed the slots for customizing how selected options and dropdown list items are rendered:
48
+ - old: `<slot name="renderOptions" />`, new: `<slot name="option" />`
49
+ - old: `<slot name="renderSelected" />`, new: `<slot name="selected" />`
50
+
48
51
  ## Installation
49
52
 
50
53
  ```sh
@@ -88,22 +91,26 @@ Full list of props/bindable variables for this component:
88
91
  <div class="table">
89
92
 
90
93
  <!-- prettier-ignore -->
91
- | name | default | description |
92
- | :--------------- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
- | `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. |
94
- | `showOptions` | `false` | Bindable boolean that controls whether the options dropdown is visible. |
95
- | `searchText` | `` | Text the user-entered to filter down on the list of options. Binds both ways, i.e. can also be used to set the input text. |
96
- | `activeOption` | `null` | Currently active option, i.e. the one the user currently hovers or navigated to with arrow keys. |
97
- | `maxSelect` | `null` | Positive integer to limit the number of options users can pick. `null` means no limit. |
98
- | `selected` | `[]` | Array of currently/pre-selected options when binding/passing as props respectively. |
99
- | `selectedLabels` | `[]` | Labels of currently selected options. |
100
- | `selectedValues` | `[]` | Values of currently selected options. |
101
- | `noOptionsMsg` | `'No matching options'` | What message to show if no options match the user-entered search string. |
102
- | `readonly` | `false` | Disable the component. It will still be rendered but users won't be able to interact with it. |
103
- | `placeholder` | `undefined` | String shown in the text input when no option is selected. |
104
- | `input` | `undefined` | Handle to the `<input>` DOM node. |
105
- | `id` | `undefined` | Applied to the `<input>` element for associating HTML form `<label>`s with this component for accessibility. Also, clicking a `<label>` with same `for` attribute as `id` will focus this component. |
106
- | `name` | `id` | Applied to the `<input>` element. If not provided, will be set to the value of `id`. Sets the key of this field in a submitted form data object. Not useful at the moment since the value is stored in Svelte state, not on the `<input>`. |
94
+ | name | default | description |
95
+ | :----------------- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
96
+ | `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. |
97
+ | `showOptions` | `false` | Bindable boolean that controls whether the options dropdown is visible. |
98
+ | `searchText` | `` | Text the user-entered to filter down on the list of options. Binds both ways, i.e. can also be used to set the input text. |
99
+ | `activeOption` | `null` | Currently active option, i.e. the one the user currently hovers or navigated to with arrow keys. |
100
+ | `maxSelect` | `null` | Positive integer to limit the number of options users can pick. `null` means no limit. |
101
+ | `selected` | `[]` | Array of currently/pre-selected options when binding/passing as props respectively. |
102
+ | `selectedLabels` | `[]` | Labels of currently selected options. |
103
+ | `selectedValues` | `[]` | Values of currently selected options. |
104
+ | `noOptionsMsg` | `'No matching options'` | What message to show if no options match the user-entered search string. |
105
+ | `readonly` | `false` | Disable the component. It will still be rendered but users won't be able to interact with it. |
106
+ | `placeholder` | `undefined` | String shown in the text input when no option is selected. |
107
+ | `input` | `undefined` | Handle to the `<input>` DOM node. |
108
+ | `id` | `undefined` | Applied to the `<input>` element for associating HTML form `<label>`s with this component for accessibility. Also, clicking a `<label>` with same `for` attribute as `id` will focus this component. |
109
+ | `name` | `id` | Applied to the `<input>` element. If not provided, will be set to the value of `id`. Sets the key of this field in a submitted form data object. Not useful at the moment since the value is stored in Svelte state, not on the `<input>`. |
110
+ | `required` | `false` | Whether forms can be submitted without selecting any options. Aborts submission, is scrolled into view and shows help "Please fill out" message when true and user tries to submit with no options selected. |
111
+ | `autoScroll` | `true` | `false` disables keeping the active dropdown items in view when going up/down the list of options with arrow keys. |
112
+ | `allowUserOptions` | `false` | Whether users are allowed to enter values not in the dropdown list. `true` means add user-defined options to the selected list only, `'append'` means add to both options and selected. |
113
+ | `loading` | `false` | Whether the component should display a spinner to indicate it's in loading state. Use `<slot name='spinner'>` to specify a custom spinner. |
107
114
 
108
115
  </div>
109
116
 
@@ -126,24 +133,27 @@ Full list of props/bindable variables for this component:
126
133
 
127
134
  ## Slots
128
135
 
129
- `MultiSelect.svelte` accepts two named slots
136
+ `MultiSelect.svelte` has 3 named slots:
130
137
 
131
- - `slot="renderOptions"`
132
- - `slot="renderSelected"`
138
+ - `slot="option"`: Customize rendering of dropdown options. Receives as props the `option` object and the zero-indexed position (`idx`) it has in the dropdown.
139
+ - `slot="selected"`: Customize rendering selected tags. Receives as props the `option` object and the zero-indexed position (`idx`) it has in the list of selected items.
140
+ - `slot="spinner"`: Custom spinner component to display when in `loading` state. Receives no props.
133
141
 
134
- to customize rendering individual options in the dropdown and the list of selected tags, respectively. Each renderer receives the full `option` object along with the zero-indexed position (`idx`) in its list, both available via the `let:` directive:
142
+ Example:
135
143
 
136
144
  ```svelte
137
145
  <MultiSelect options={[`Banana`, `Apple`, `Mango`]}>
138
- <span let:idx let:option slot="renderOptions">
146
+ <span let:idx let:option slot="option">
139
147
  {idx + 1}. {option.label}
140
148
  {option.label === `Mango` ? `🎉` : ``}
141
149
  </span>
142
150
 
143
- <span let:idx let:option slot="renderSelected">
151
+ <span let:idx let:option slot="selected">
144
152
  #{idx + 1}
145
153
  {option.label}
146
154
  </span>
155
+
156
+ <CustomSpinner slot="spinner">
147
157
  </MultiSelect>
148
158
  ```
149
159
 
@@ -233,8 +243,11 @@ If you only want to make small adjustments, you can pass the following CSS varia
233
243
  - `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.
234
244
  - `div.multiselect > ul.options`
235
245
  - `background: var(--sms-options-bg, white)`: Background of dropdown list.
246
+ - `max-height: var(--sms-options-max-height, 50vh)`: Maximum height of options dropdown.
236
247
  - `overscroll-behavior: var(--sms-options-overscroll, none)`: Whether scroll events bubble to parent elements when reaching the top/bottom of the options dropdown. See [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior).
237
248
  - `box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);`: Box shadow of dropdown list.
249
+ - `div.multiselect > ul.options > li`
250
+ - `scroll-margin: var(--sms-options-scroll-margin, 100px)`: Top/bottom margin to keep between dropdown list items and top/bottom screen edge when auto-scrolling list to keep items in view.
238
251
  - `div.multiselect > ul.options > li.selected`
239
252
  - `border-left: var(--sms-li-selected-border-left, 3pt solid var(--sms-selected-color, green))`
240
253
  - `background: var(--sms-li-selected-bg, inherit)`: Background of selected list items in options pane.