svelte-multiselect 4.0.5 → 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,5 +1,4 @@
1
1
  <script >import { createEventDispatcher, tick } from 'svelte';
2
- import { fly } from 'svelte/transition';
3
2
  import CircleSpinner from './CircleSpinner.svelte';
4
3
  import { CrossIcon, ExpandIcon, DisabledIcon } from './icons';
5
4
  import Wiggle from './Wiggle.svelte';
@@ -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;
@@ -49,29 +49,26 @@ if (!(options?.length > 0))
49
49
  if (!Array.isArray(selected))
50
50
  console.error(`selected prop must be an array`);
51
51
  const dispatch = createEventDispatcher();
52
- function isObject(item) {
52
+ let activeMsg = false; // controls active state of <li>{addOptionMsg}</li>
53
+ function is_object(item) {
53
54
  return typeof item === `object` && !Array.isArray(item) && item !== null;
54
55
  }
55
56
  // process proto options to full ones with mandatory labels
56
- $: _options = options.map((rawOp) => {
57
- if (isObject(rawOp)) {
58
- const option = { ...rawOp };
57
+ $: _options = options.map((raw_op) => {
58
+ if (is_object(raw_op)) {
59
+ const option = { ...raw_op };
59
60
  if (option.value === undefined)
60
61
  option.value = option.label;
61
62
  return option;
62
63
  }
63
64
  else {
64
- if (![`string`, `number`].includes(typeof rawOp)) {
65
- console.warn(`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}`);
66
67
  }
67
68
  // even if we logged error above, try to proceed hoping user knows what they're doing
68
- return { label: rawOp, value: rawOp };
69
+ return { label: raw_op, value: raw_op };
69
70
  }
70
71
  });
71
- $: labels = _options.map((op) => op.label);
72
- $: if (new Set(labels).size !== options.length) {
73
- console.warn(`Option labels should be unique. Duplicates found: ${labels.filter((label, idx) => labels.indexOf(label) !== idx)}`);
74
- }
75
72
  let wiggle = false;
76
73
  $: selectedLabels = selected.map((op) => op.label);
77
74
  $: selectedValues = selected.map((op) => op.value);
@@ -83,38 +80,57 @@ $: if (formValue)
83
80
  // options matching the current search text
84
81
  $: matchingOptions = _options.filter((op) => filterFunc(op, searchText) && !selectedLabels.includes(op.label));
85
82
  $: matchingEnabledOptions = matchingOptions.filter((op) => !op.disabled);
83
+ // add an option to selected list
86
84
  function add(label) {
87
85
  if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
88
86
  wiggle = true;
89
- if (!selectedLabels.includes(label) &&
90
- // for maxselect = 1 we always replace current option with new selection
91
- (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
+ }
92
100
  searchText = ``; // reset search string on selection
93
- const option = _options.find((op) => op.label === label);
94
101
  if (!option) {
95
102
  console.error(`MultiSelect: option with label ${label} not found`);
96
103
  return;
97
104
  }
98
105
  if (maxSelect === 1) {
106
+ // for maxselect = 1 we always replace current option with new one
99
107
  selected = [option];
100
108
  }
101
109
  else {
102
- selected = [option, ...selected];
110
+ selected = [...selected, option];
103
111
  }
104
112
  if (selected.length === maxSelect)
105
113
  setOptionsVisible(false);
114
+ else
115
+ input?.focus();
106
116
  dispatch(`add`, { option });
107
117
  dispatch(`change`, { option, type: `add` });
108
118
  }
109
119
  }
120
+ // remove an option from selected list
110
121
  function remove(label) {
111
122
  if (selected.length === 0)
112
123
  return;
113
- 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 });
114
131
  if (!option) {
115
132
  return console.error(`MultiSelect: option with label ${label} not found`);
116
133
  }
117
- selected = selected.filter((option) => label !== option.label);
118
134
  dispatch(`remove`, { option });
119
135
  dispatch(`change`, { option, type: `remove` });
120
136
  }
@@ -141,16 +157,15 @@ async function handleKeydown(event) {
141
157
  }
142
158
  // on enter key: toggle active option and reset search text
143
159
  else if (event.key === `Enter`) {
160
+ event.preventDefault(); // prevent enter key from triggering form submission
144
161
  if (activeOption) {
145
162
  const { label } = activeOption;
146
163
  selectedLabels.includes(label) ? remove(label) : add(label);
147
164
  searchText = ``;
148
165
  }
149
- else if ([true, `append`].includes(allowUserOptions)) {
150
- selected = [...selected, { label: searchText, value: searchText }];
151
- if (allowUserOptions === `append`)
152
- options = [...options, { label: searchText, value: searchText }];
153
- 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);
154
169
  }
155
170
  // no active option and no search text means the options dropdown is closed
156
171
  // in which case enter means open it
@@ -159,11 +174,17 @@ async function handleKeydown(event) {
159
174
  }
160
175
  // on up/down arrow keys: update active option
161
176
  else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
162
- if (activeOption === null) {
163
- // 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) {
164
179
  activeOption = matchingEnabledOptions[0];
165
180
  return;
166
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
+ }
167
188
  const increment = event.key === `ArrowUp` ? -1 : 1;
168
189
  const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
169
190
  if (newActiveIdx < 0) {
@@ -185,10 +206,8 @@ async function handleKeydown(event) {
185
206
  }
186
207
  }
187
208
  // on backspace key: remove last selected option
188
- else if (event.key === `Backspace`) {
189
- const label = selectedLabels.pop();
190
- if (label && !searchText)
191
- remove(label);
209
+ else if (event.key === `Backspace` && selectedLabels.length > 0 && !searchText) {
210
+ remove(selectedLabels.at(-1));
192
211
  }
193
212
  }
194
213
  const removeAll = () => {
@@ -305,48 +324,55 @@ display above those of another following shortly after it -->
305
324
  {/if}
306
325
  {/if}
307
326
 
308
- {#key showOptions}
309
- <ul
310
- class:hidden={!showOptions}
311
- class="options {ulOptionsClass}"
312
- transition:fly|local={{ duration: 300, y: 40 }}
313
- >
314
- {#each matchingOptions as option, idx}
315
- {@const { label, disabled, title = null, selectedTitle } = option}
316
- {@const { disabledTitle = defaultDisabledTitle } = option}
317
- {@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}
318
358
  <li
319
- on:mouseup|preventDefault|stopPropagation
320
- on:mousedown|preventDefault|stopPropagation={() => {
321
- if (disabled) return
322
- isSelected(label) ? remove(label) : add(label)
323
- }}
324
- title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
325
- class:selected={isSelected(label)}
326
- class:active
327
- class:disabled
328
- class="{liOptionClass} {active ? liActiveOptionClass : ``}"
329
- on:mouseover={() => {
330
- if (disabled) return
331
- activeOption = option
332
- }}
333
- on:focus={() => {
334
- if (disabled) return
335
- activeOption = option
336
- }}
337
- on:mouseout={() => (activeOption = null)}
338
- 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)}
339
367
  aria-selected="false"
340
368
  >
341
- <slot name="option" {option} {idx}>
342
- {option.label}
343
- </slot>
369
+ {addOptionMsg}
344
370
  </li>
345
371
  {:else}
346
372
  <span>{noOptionsMsg}</span>
347
- {/each}
348
- </ul>
349
- {/key}
373
+ {/if}
374
+ {/each}
375
+ </ul>
350
376
  </div>
351
377
 
352
378
  <style>
@@ -455,9 +481,14 @@ display above those of another following shortly after it -->
455
481
  max-height: var(--sms-options-max-height, 50vh);
456
482
  overscroll-behavior: var(--sms-options-overscroll, none);
457
483
  box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);
484
+ transition: all 0.2s;
485
+ opacity: 1;
486
+ transform: translateY(0);
458
487
  }
459
488
  :where(div.multiselect > ul.options.hidden) {
460
489
  visibility: hidden;
490
+ opacity: 0;
491
+ transform: translateY(50px);
461
492
  }
462
493
  :where(div.multiselect > ul.options > li) {
463
494
  padding: 3pt 2ex;
@@ -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.5",
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-preprocess": "^4.10.5",
33
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
@@ -105,6 +105,7 @@ Full list of props/bindable variables for this component:
105
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. |
106
106
  | `autoScroll` | `true` | `false` disables keeping the active dropdown items in view when going up/down the list of options with arrow keys. |
107
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. |
108
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. |
109
110
  | `removeBtnTitle` | `'Remove'` | Title text to display when user hovers over button (cross icon) to remove selected option. |
110
111
  | `removeAllTitle` | `'Remove all'` | Title text to display when user hovers over remove-all button. |
@@ -281,7 +282,7 @@ The second method allows you to pass in custom classes to the important DOM elem
281
282
  - `liOptionClass`
282
283
  - `liActiveOptionClass`
283
284
 
284
- 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:
285
286
 
286
287
  ```svelte
287
288
  <div class="multiselect {outerDivClass}">