svelte-multiselect 4.0.3 → 4.0.6

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,9 +1,7 @@
1
- <script >import { createEventDispatcher, onMount, tick } from 'svelte';
2
- import { fly } from 'svelte/transition';
1
+ <script >import { createEventDispatcher, tick } from 'svelte';
3
2
  import CircleSpinner from './CircleSpinner.svelte';
4
3
  import { CrossIcon, ExpandIcon, DisabledIcon } from './icons';
5
4
  import Wiggle from './Wiggle.svelte';
6
- export let selected = [];
7
5
  export let selectedLabels = [];
8
6
  export let selectedValues = [];
9
7
  export let searchText = ``;
@@ -13,6 +11,7 @@ export let maxSelectMsg = null;
13
11
  export let disabled = false;
14
12
  export let disabledTitle = `This field is disabled`;
15
13
  export let options;
14
+ export let selected = options.filter((op) => op?.preselected) ?? [];
16
15
  export let input = null;
17
16
  export let outerDiv = null;
18
17
  export let placeholder = undefined;
@@ -36,6 +35,7 @@ export let removeBtnTitle = `Remove`;
36
35
  export let removeAllTitle = `Remove all`;
37
36
  export let defaultDisabledTitle = `This option is disabled`;
38
37
  export let allowUserOptions = false;
38
+ export let addOptionMsg = `Create this option...`;
39
39
  export let autoScroll = true;
40
40
  export let loading = false;
41
41
  export let required = false;
@@ -48,34 +48,27 @@ if (!(options?.length > 0))
48
48
  console.error(`MultiSelect missing options`);
49
49
  if (!Array.isArray(selected))
50
50
  console.error(`selected prop must be an array`);
51
- onMount(() => {
52
- selected = _options.filter((op) => op?.preselected) ?? [];
53
- });
54
51
  const dispatch = createEventDispatcher();
55
- function isObject(item) {
52
+ let activeMsg = false; // controls active state of <li>{addOptionMsg}</li>
53
+ function is_object(item) {
56
54
  return typeof item === `object` && !Array.isArray(item) && item !== null;
57
55
  }
58
56
  // process proto options to full ones with mandatory labels
59
- $: _options = options.map((rawOp) => {
60
- // convert to objects internally if user passed list of strings or numbers as options
61
- if (isObject(rawOp)) {
62
- const op = { ...rawOp };
63
- if (!op.value)
64
- op.value = op.label;
65
- return op;
57
+ $: _options = options.map((raw_op) => {
58
+ if (is_object(raw_op)) {
59
+ const option = { ...raw_op };
60
+ if (option.value === undefined)
61
+ option.value = option.label;
62
+ return option;
66
63
  }
67
64
  else {
68
- if (![`string`, `number`].includes(typeof rawOp)) {
69
- console.error(`MultiSelect options must be objects, strings or numbers, got ${typeof rawOp}`);
65
+ if (![`string`, `number`].includes(typeof raw_op)) {
66
+ console.warn(`MultiSelect options must be objects, strings or numbers, got ${typeof raw_op}`);
70
67
  }
71
68
  // even if we logged error above, try to proceed hoping user knows what they're doing
72
- return { label: rawOp, value: rawOp };
69
+ return { label: raw_op, value: raw_op };
73
70
  }
74
71
  });
75
- $: labels = _options.map((op) => op.label);
76
- $: if (new Set(labels).size !== options.length) {
77
- console.error(`Option labels must be unique. Duplicates found: ${labels.filter((label, idx) => labels.indexOf(label) !== idx)}`);
78
- }
79
72
  let wiggle = false;
80
73
  $: selectedLabels = selected.map((op) => op.label);
81
74
  $: selectedValues = selected.map((op) => op.value);
@@ -87,38 +80,57 @@ $: if (formValue)
87
80
  // options matching the current search text
88
81
  $: matchingOptions = _options.filter((op) => filterFunc(op, searchText) && !selectedLabels.includes(op.label));
89
82
  $: matchingEnabledOptions = matchingOptions.filter((op) => !op.disabled);
83
+ // add an option to selected list
90
84
  function add(label) {
91
85
  if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
92
86
  wiggle = true;
93
- if (!selectedLabels.includes(label) &&
94
- // for maxselect = 1 we always replace current option with new selection
95
- (maxSelect === null || maxSelect === 1 || selected.length < maxSelect)) {
87
+ // to prevent duplicate selection, we could add `&& !selectedLabels.includes(label)`
88
+ if (maxSelect === null || maxSelect === 1 || selected.length < maxSelect) {
89
+ // first check if we find option in the options list
90
+ let option = _options.find((op) => op.label === label);
91
+ if (!option && // this has the side-effect of not allowing to user to add the same
92
+ // custom option twice in append mode
93
+ [true, `append`].includes(allowUserOptions) &&
94
+ searchText.length > 0) {
95
+ // user entered text but no options match, so if allowUserOptions=true | 'append', we create new option
96
+ option = { label: searchText, value: searchText };
97
+ if (allowUserOptions === `append`)
98
+ _options = [..._options, option];
99
+ }
96
100
  searchText = ``; // reset search string on selection
97
- const option = _options.find((op) => op.label === label);
98
101
  if (!option) {
99
102
  console.error(`MultiSelect: option with label ${label} not found`);
100
103
  return;
101
104
  }
102
105
  if (maxSelect === 1) {
106
+ // for maxselect = 1 we always replace current option with new one
103
107
  selected = [option];
104
108
  }
105
109
  else {
106
- selected = [option, ...selected];
110
+ selected = [...selected, option];
107
111
  }
108
112
  if (selected.length === maxSelect)
109
113
  setOptionsVisible(false);
114
+ else
115
+ input?.focus();
110
116
  dispatch(`add`, { option });
111
117
  dispatch(`change`, { option, type: `add` });
112
118
  }
113
119
  }
120
+ // remove an option from selected list
114
121
  function remove(label) {
115
122
  if (selected.length === 0)
116
123
  return;
117
- const option = _options.find((option) => option.label === label);
124
+ selected.splice(selectedLabels.lastIndexOf(label), 1);
125
+ selected = selected; // Svelte rerender after in-place splice
126
+ const option = _options.find((option) => option.label === label) ??
127
+ // if option with label could not be found but allowUserOptions is truthy,
128
+ // assume it was created by user and create correspondidng option object
129
+ // on the fly for use as event payload
130
+ (allowUserOptions && { label, value: label });
118
131
  if (!option) {
119
132
  return console.error(`MultiSelect: option with label ${label} not found`);
120
133
  }
121
- selected = selected.filter((option) => label !== option.label);
122
134
  dispatch(`remove`, { option });
123
135
  dispatch(`change`, { option, type: `remove` });
124
136
  }
@@ -145,16 +157,15 @@ async function handleKeydown(event) {
145
157
  }
146
158
  // on enter key: toggle active option and reset search text
147
159
  else if (event.key === `Enter`) {
160
+ event.preventDefault(); // prevent enter key from triggering form submission
148
161
  if (activeOption) {
149
162
  const { label } = activeOption;
150
163
  selectedLabels.includes(label) ? remove(label) : add(label);
151
164
  searchText = ``;
152
165
  }
153
- else if ([true, `append`].includes(allowUserOptions)) {
154
- selected = [...selected, { label: searchText, value: searchText }];
155
- if (allowUserOptions === `append`)
156
- options = [...options, { label: searchText, value: searchText }];
157
- searchText = ``;
166
+ else if (allowUserOptions && searchText.length > 0) {
167
+ // user entered text but no options match, so if allowUserOptions is truthy, we create new option
168
+ add(searchText);
158
169
  }
159
170
  // no active option and no search text means the options dropdown is closed
160
171
  // in which case enter means open it
@@ -163,11 +174,17 @@ async function handleKeydown(event) {
163
174
  }
164
175
  // on up/down arrow keys: update active option
165
176
  else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
166
- if (activeOption === null) {
167
- // if no option is active yet, make first one active
177
+ // if no option is active yet, but there are matching options, make first one active
178
+ if (activeOption === null && matchingEnabledOptions.length > 0) {
168
179
  activeOption = matchingEnabledOptions[0];
169
180
  return;
170
181
  }
182
+ else if (allowUserOptions && searchText.length > 0) {
183
+ // if allowUserOptions is truthy and user entered text but no options match, we make
184
+ // <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
185
+ activeMsg = !activeMsg;
186
+ return;
187
+ }
171
188
  const increment = event.key === `ArrowUp` ? -1 : 1;
172
189
  const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
173
190
  if (newActiveIdx < 0) {
@@ -189,10 +206,8 @@ async function handleKeydown(event) {
189
206
  }
190
207
  }
191
208
  // on backspace key: remove last selected option
192
- else if (event.key === `Backspace`) {
193
- const label = selectedLabels.pop();
194
- if (label && !searchText)
195
- remove(label);
209
+ else if (event.key === `Backspace` && selectedLabels.length > 0 && !searchText) {
210
+ remove(selectedLabels.at(-1));
196
211
  }
197
212
  }
198
213
  const removeAll = () => {
@@ -309,48 +324,55 @@ display above those of another following shortly after it -->
309
324
  {/if}
310
325
  {/if}
311
326
 
312
- {#key showOptions}
313
- <ul
314
- class:hidden={!showOptions}
315
- class="options {ulOptionsClass}"
316
- transition:fly|local={{ duration: 300, y: 40 }}
317
- >
318
- {#each matchingOptions as option, idx}
319
- {@const { label, disabled, title = null, selectedTitle } = option}
320
- {@const { disabledTitle = defaultDisabledTitle } = option}
321
- {@const active = activeOption?.label === label}
327
+ <ul class:hidden={!showOptions} class="options {ulOptionsClass}">
328
+ {#each matchingOptions as option, idx}
329
+ {@const { label, disabled, title = null, selectedTitle } = option}
330
+ {@const { disabledTitle = defaultDisabledTitle } = option}
331
+ {@const active = activeOption?.label === label}
332
+ <li
333
+ on:mousedown|stopPropagation
334
+ on:mouseup|stopPropagation={() => {
335
+ if (!disabled) isSelected(label) ? remove(label) : add(label)
336
+ }}
337
+ title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
338
+ class:selected={isSelected(label)}
339
+ class:active
340
+ class:disabled
341
+ class="{liOptionClass} {active ? liActiveOptionClass : ``}"
342
+ on:mouseover={() => {
343
+ if (!disabled) activeOption = option
344
+ }}
345
+ on:focus={() => {
346
+ if (!disabled) activeOption = option
347
+ }}
348
+ on:mouseout={() => (activeOption = null)}
349
+ on:blur={() => (activeOption = null)}
350
+ aria-selected="false"
351
+ >
352
+ <slot name="option" {option} {idx}>
353
+ {option.label}
354
+ </slot>
355
+ </li>
356
+ {:else}
357
+ {#if allowUserOptions && searchText}
322
358
  <li
323
- on:mouseup|preventDefault|stopPropagation
324
- on:mousedown|preventDefault|stopPropagation={() => {
325
- if (disabled) return
326
- isSelected(label) ? remove(label) : add(label)
327
- }}
328
- title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
329
- class:selected={isSelected(label)}
330
- class:active
331
- class:disabled
332
- class="{liOptionClass} {active ? liActiveOptionClass : ``}"
333
- on:mouseover={() => {
334
- if (disabled) return
335
- activeOption = option
336
- }}
337
- on:focus={() => {
338
- if (disabled) return
339
- activeOption = option
340
- }}
341
- on:mouseout={() => (activeOption = null)}
342
- on:blur={() => (activeOption = null)}
359
+ on:mousedown|stopPropagation
360
+ on:mouseup|stopPropagation={() => add(searchText)}
361
+ title={addOptionMsg}
362
+ class:active={activeMsg}
363
+ on:mouseover={() => (activeMsg = true)}
364
+ on:focus={() => (activeMsg = true)}
365
+ on:mouseout={() => (activeMsg = false)}
366
+ on:blur={() => (activeMsg = false)}
343
367
  aria-selected="false"
344
368
  >
345
- <slot name="option" {option} {idx}>
346
- {option.label}
347
- </slot>
369
+ {addOptionMsg}
348
370
  </li>
349
371
  {:else}
350
372
  <span>{noOptionsMsg}</span>
351
- {/each}
352
- </ul>
353
- {/key}
373
+ {/if}
374
+ {/each}
375
+ </ul>
354
376
  </div>
355
377
 
356
378
  <style>
@@ -459,9 +481,14 @@ display above those of another following shortly after it -->
459
481
  max-height: var(--sms-options-max-height, 50vh);
460
482
  overscroll-behavior: var(--sms-options-overscroll, none);
461
483
  box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);
484
+ transition: all 0.2s;
485
+ opacity: 1;
486
+ transform: translateY(0);
462
487
  }
463
488
  :where(div.multiselect > ul.options.hidden) {
464
489
  visibility: hidden;
490
+ opacity: 0;
491
+ transform: translateY(50px);
465
492
  }
466
493
  :where(div.multiselect > ul.options > li) {
467
494
  padding: 3pt 2ex;
@@ -2,7 +2,6 @@ import { SvelteComponentTyped } from "svelte";
2
2
  import type { Option, Primitive, ProtoOption } from './';
3
3
  declare const __propDef: {
4
4
  props: {
5
- selected?: Option[] | undefined;
6
5
  selectedLabels?: Primitive[] | undefined;
7
6
  selectedValues?: Primitive[] | undefined;
8
7
  searchText?: string | undefined;
@@ -12,6 +11,7 @@ declare const __propDef: {
12
11
  disabled?: boolean | undefined;
13
12
  disabledTitle?: string | undefined;
14
13
  options: ProtoOption[];
14
+ selected?: Option[] | undefined;
15
15
  input?: HTMLInputElement | null | undefined;
16
16
  outerDiv?: HTMLDivElement | null | undefined;
17
17
  placeholder?: string | undefined;
@@ -31,6 +31,7 @@ declare const __propDef: {
31
31
  removeAllTitle?: string | undefined;
32
32
  defaultDisabledTitle?: string | undefined;
33
33
  allowUserOptions?: boolean | "append" | undefined;
34
+ addOptionMsg?: string | undefined;
34
35
  autoScroll?: boolean | undefined;
35
36
  loading?: boolean | undefined;
36
37
  required?: boolean | undefined;
@@ -38,7 +39,7 @@ declare const __propDef: {
38
39
  invalid?: boolean | undefined;
39
40
  };
40
41
  events: {
41
- mouseup: MouseEvent;
42
+ mousedown: MouseEvent;
42
43
  } & {
43
44
  [evt: string]: CustomEvent<any>;
44
45
  };
package/package.json CHANGED
@@ -5,37 +5,38 @@
5
5
  "homepage": "https://svelte-multiselect.netlify.app",
6
6
  "repository": "https://github.com/janosh/svelte-multiselect",
7
7
  "license": "MIT",
8
- "version": "4.0.3",
8
+ "version": "4.0.6",
9
9
  "type": "module",
10
10
  "svelte": "index.js",
11
11
  "bugs": "https://github.com/janosh/svelte-multiselect/issues",
12
12
  "devDependencies": {
13
13
  "@sveltejs/adapter-static": "^1.0.0-next.29",
14
- "@sveltejs/kit": "^1.0.0-next.302",
15
- "@sveltejs/vite-plugin-svelte": "^1.0.0-next.40",
16
- "@typescript-eslint/eslint-plugin": "^5.16.0",
17
- "@typescript-eslint/parser": "^5.16.0",
18
- "@vitest/ui": "^0.7.9",
19
- "eslint": "^8.11.0",
14
+ "@sveltejs/kit": "^1.0.0-next.308",
15
+ "@sveltejs/vite-plugin-svelte": "^1.0.0-next.41",
16
+ "@typescript-eslint/eslint-plugin": "^5.18.0",
17
+ "@typescript-eslint/parser": "^5.18.0",
18
+ "@vitest/ui": "^0.9.0",
19
+ "c8": "^7.11.0",
20
+ "eslint": "^8.12.0",
20
21
  "eslint-plugin-svelte3": "^3.4.1",
21
22
  "hastscript": "^7.0.2",
22
23
  "jsdom": "^19.0.0",
23
24
  "mdsvex": "^0.10.5",
24
- "playwright": "^1.20.0",
25
- "prettier": "^2.6.0",
25
+ "playwright": "^1.20.2",
26
+ "prettier": "^2.6.2",
26
27
  "prettier-plugin-svelte": "^2.6.0",
27
28
  "rehype-autolink-headings": "^6.1.1",
28
29
  "rehype-slug": "^5.0.1",
29
- "svelte": "^3.46.4",
30
+ "svelte": "^3.46.6",
30
31
  "svelte-check": "^2.4.6",
31
32
  "svelte-github-corner": "^0.1.0",
32
- "svelte-preprocess": "^4.10.4",
33
- "svelte-toc": "^0.2.8",
33
+ "svelte-preprocess": "^4.10.5",
34
+ "svelte-toc": "^0.2.9",
34
35
  "svelte2tsx": "^0.5.6",
35
36
  "tslib": "^2.3.1",
36
- "typescript": "^4.6.2",
37
- "vite": "^2.8.6",
38
- "vitest": "^0.7.9"
37
+ "typescript": "^4.6.3",
38
+ "vite": "^2.9.1",
39
+ "vitest": "^0.9.0"
39
40
  },
40
41
  "keywords": [
41
42
  "svelte",
package/readme.md CHANGED
@@ -1,24 +1,21 @@
1
1
  <h1 align="center">
2
- <img src="https://raw.githubusercontent.com/janosh/svelte-toc/main/static/favicon.svg" alt="Svelte MultiSelect" height=60>
3
- <br>&ensp;Svelte MultiSelect
2
+ <img src="https://raw.githubusercontent.com/janosh/svelte-multiselect/main/static/favicon.svg" alt="Svelte MultiSelect" height="60" width="60">
3
+ <br class="hide-in-docs">&ensp;Svelte MultiSelect
4
4
  </h1>
5
5
 
6
6
  <h4 align="center">
7
7
 
8
+ [![REPL](https://img.shields.io/badge/Svelte-REPL-blue)](https://svelte.dev/repl/a5a14b8f15d64cb083b567292480db05)
8
9
  [![Tests](https://github.com/janosh/svelte-multiselect/actions/workflows/test.yml/badge.svg)](https://github.com/janosh/svelte-multiselect/actions/workflows/test.yml)
9
10
  [![Netlify Status](https://api.netlify.com/api/v1/badges/a45b62c3-ea45-4cfd-9912-77ec4fc8d7e8/deploy-status)](https://app.netlify.com/sites/svelte-multiselect/deploys)
10
- [![NPM version](https://img.shields.io/npm/v/svelte-multiselect?color=blue&logo=NPM)](https://npmjs.com/package/svelte-multiselect)
11
- [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/janosh/svelte-multiselect/main.svg)](https://results.pre-commit.ci/latest/github/janosh/svelte-multiselect/main)
12
- [![Needs Svelte version](https://img.shields.io/npm/dependency-version/svelte-multiselect/dev/svelte)](https://github.com/sveltejs/svelte/blob/master/CHANGELOG.md)
11
+ [![NPM version](https://img.shields.io/npm/v/svelte-multiselect?logo=NPM&color=purple)](https://npmjs.com/package/svelte-multiselect)
12
+ [![Needs Svelte version](https://img.shields.io/npm/dependency-version/svelte-multiselect/dev/svelte?color=teal)](https://github.com/sveltejs/svelte/blob/master/CHANGELOG.md)
13
13
 
14
14
  </h4>
15
15
 
16
- **Keyboard-friendly, zero-dependency multi-select Svelte component.**
16
+ **Keyboard-friendly, accessible multi-select Svelte component.**
17
17
  <strong class="hide-in-docs">
18
- <a href="https://svelte-multiselect.netlify.app">Docs</a> &bull;
19
- </strong>
20
- <strong>
21
- <a href="https://svelte.dev/repl/a5a14b8f15d64cb083b567292480db05">REPL</a>
18
+ <a href="https://svelte-multiselect.netlify.app">Docs</a>
22
19
  </strong>
23
20
 
24
21
  <slot name="examples" />
@@ -108,6 +105,7 @@ Full list of props/bindable variables for this component:
108
105
  | `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. |
109
106
  | `autoScroll` | `true` | `false` disables keeping the active dropdown items in view when going up/down the list of options with arrow keys. |
110
107
  | `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. |
108
+ | `addOptionMsg` | `'Create this option...'` | Message shown to users after entering text when no options match their query and `allowUserOptions` is truthy. |
111
109
  | `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. |
112
110
  | `removeBtnTitle` | `'Remove'` | Title text to display when user hovers over button (cross icon) to remove selected option. |
113
111
  | `removeAllTitle` | `'Remove all'` | Title text to display when user hovers over remove-all button. |
@@ -284,7 +282,7 @@ The second method allows you to pass in custom classes to the important DOM elem
284
282
  - `liOptionClass`
285
283
  - `liActiveOptionClass`
286
284
 
287
- This simplified version of the DOM structure of this component shows where these classes are inserted:
285
+ This simplified version of the DOM structure of the component shows where these classes are inserted:
288
286
 
289
287
  ```svelte
290
288
  <div class="multiselect {outerDivClass}">
@@ -384,7 +382,7 @@ export default {
384
382
  }
385
383
  ```
386
384
 
387
- Here's a [Stackblitz example](https://stackblitz.com/fork/github/davipon/test-svelte-multiselect?initialPath=__vitest__) that also uses [`'vitest-svelte-kit'`](https://github.com/nickbreaton/vitest-svelte-kit).
385
+ Here's a [Stackblitz example](https://stackblitz.com/fork/github/davipon/test-svelte-multiselect?initialPath=__vitest__) that also uses [`vitest-svelte-kit`](https://github.com/nickbreaton/vitest-svelte-kit).
388
386
 
389
387
  ## Want to contribute?
390
388