svelte-multiselect 3.2.3 → 4.0.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.
@@ -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,14 +1,17 @@
1
- <script >import { createEventDispatcher, onMount } from 'svelte';
1
+ <script >import { createEventDispatcher, onMount, tick } from 'svelte';
2
2
  import { fly } from 'svelte/transition';
3
- import { onClickOutside } from './actions';
4
- import { CrossIcon, ExpandIcon, ReadOnlyIcon } from './icons';
3
+ import CircleSpinner from './CircleSpinner.svelte';
4
+ import { CrossIcon, ExpandIcon, DisabledIcon } from './icons';
5
5
  import Wiggle from './Wiggle.svelte';
6
6
  export let selected = [];
7
7
  export let selectedLabels = [];
8
8
  export let selectedValues = [];
9
+ export let searchText = ``;
10
+ export let showOptions = false;
9
11
  export let maxSelect = null; // null means any number of options are selectable
10
- export let maxSelectMsg = (current, max) => `${current}/${max}`;
11
- export let readonly = false;
12
+ export let maxSelectMsg = null;
13
+ export let disabled = false;
14
+ export let disabledTitle = `This field is disabled`;
12
15
  export let options;
13
16
  export let input = null;
14
17
  export let placeholder = undefined;
@@ -26,10 +29,15 @@ export let ulSelectedClass = ``;
26
29
  export let liSelectedClass = ``;
27
30
  export let ulOptionsClass = ``;
28
31
  export let liOptionClass = ``;
32
+ export let liActiveOptionClass = ``;
29
33
  export let removeBtnTitle = `Remove`;
30
34
  export let removeAllTitle = `Remove all`;
31
- // https://github.com/sveltejs/svelte/issues/6964
32
35
  export let defaultDisabledTitle = `This option is disabled`;
36
+ export let allowUserOptions = false;
37
+ export let autoScroll = true;
38
+ export let loading = false;
39
+ export let required = false;
40
+ export let autocomplete = `off`;
33
41
  if (maxSelect !== null && maxSelect < 0) {
34
42
  console.error(`maxSelect must be null or positive integer, got ${maxSelect}`);
35
43
  }
@@ -38,9 +46,13 @@ if (!(options?.length > 0))
38
46
  if (!Array.isArray(selected))
39
47
  console.error(`selected prop must be an array`);
40
48
  onMount(() => {
41
- selected = _options.filter((op) => op?.preselected);
49
+ selected = _options.filter((op) => op?.preselected) ?? [];
42
50
  });
43
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(`,`);
55
+ const dispatch = createEventDispatcher();
44
56
  function isObject(item) {
45
57
  return typeof item === `object` && !Array.isArray(item) && item !== null;
46
58
  }
@@ -67,27 +79,15 @@ $: if (new Set(labels).size !== options.length) {
67
79
  }
68
80
  $: selectedLabels = selected.map((op) => op.label);
69
81
  $: selectedValues = selected.map((op) => op.value);
70
- const dispatch = createEventDispatcher();
71
- let searchText = ``;
72
- let showOptions = false;
73
82
  // options matching the current search text
74
- $: matchingOptions = _options.filter((op) => filterFunc(op, searchText));
83
+ $: matchingOptions = _options.filter((op) => filterFunc(op, searchText) && !selectedLabels.includes(op.label));
75
84
  $: matchingEnabledOptions = matchingOptions.filter((op) => !op.disabled);
76
- $: if (
77
- // if there was an active option but it's not in the filtered list of options
78
- (activeOption &&
79
- !matchingEnabledOptions.map((op) => op.label).includes(activeOption.label)) ||
80
- // or there's no active option but the user entered search text
81
- (!activeOption && searchText))
82
- // make the first filtered option active
83
- activeOption = matchingEnabledOptions[0];
84
85
  function add(label) {
85
86
  if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
86
87
  wiggle = true;
87
- if (!readonly &&
88
- !selectedLabels.includes(label) &&
88
+ if (!selectedLabels.includes(label) &&
89
89
  // for maxselect = 1 we always replace current option with new selection
90
- (maxSelect == null || maxSelect == 1 || selected.length < maxSelect)) {
90
+ (maxSelect === null || maxSelect === 1 || selected.length < maxSelect)) {
91
91
  searchText = ``; // reset search string on selection
92
92
  const option = _options.find((op) => op.label === label);
93
93
  if (!option) {
@@ -107,14 +107,19 @@ function add(label) {
107
107
  }
108
108
  }
109
109
  function remove(label) {
110
- if (selected.length === 0 || readonly)
110
+ if (selected.length === 0)
111
111
  return;
112
- selected = selected.filter((option) => label !== option.label);
113
112
  const option = _options.find((option) => option.label === label);
113
+ if (!option) {
114
+ return console.error(`MultiSelect: option with label ${label} not found`);
115
+ }
116
+ selected = selected.filter((option) => label !== option.label);
114
117
  dispatch(`remove`, { option });
115
118
  dispatch(`change`, { option, type: `remove` });
116
119
  }
117
120
  function setOptionsVisible(show) {
121
+ if (disabled)
122
+ return;
118
123
  showOptions = show;
119
124
  if (show)
120
125
  input?.focus();
@@ -124,7 +129,7 @@ function setOptionsVisible(show) {
124
129
  }
125
130
  }
126
131
  // handle all keyboard events this component receives
127
- function handleKeydown(event) {
132
+ async function handleKeydown(event) {
128
133
  // on escape: dismiss options dropdown and reset search text
129
134
  if (event.key === `Escape`) {
130
135
  setOptionsVisible(false);
@@ -136,7 +141,15 @@ function handleKeydown(event) {
136
141
  const { label } = activeOption;
137
142
  selectedLabels.includes(label) ? remove(label) : add(label);
138
143
  searchText = ``;
139
- } // no active option means the options dropdown is closed in which case enter means open it
144
+ }
145
+ else if ([true, `append`].includes(allowUserOptions)) {
146
+ selected = [...selected, { label: searchText, value: searchText }];
147
+ if (allowUserOptions === `append`)
148
+ options = [...options, { label: searchText, value: searchText }];
149
+ searchText = ``;
150
+ }
151
+ // no active option and no search text means the options dropdown is closed
152
+ // in which case enter means open it
140
153
  else
141
154
  setOptionsVisible(true);
142
155
  }
@@ -149,31 +162,25 @@ function handleKeydown(event) {
149
162
  }
150
163
  const increment = event.key === `ArrowUp` ? -1 : 1;
151
164
  const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
152
- const ulOps = document.querySelector(`ul.options`);
153
165
  if (newActiveIdx < 0) {
154
166
  // wrap around top
155
167
  activeOption = matchingEnabledOptions[matchingEnabledOptions.length - 1];
156
- if (ulOps)
157
- ulOps.scrollTop = ulOps.scrollHeight;
158
168
  }
159
169
  else if (newActiveIdx === matchingEnabledOptions.length) {
160
170
  // wrap around bottom
161
171
  activeOption = matchingEnabledOptions[0];
162
- if (ulOps)
163
- ulOps.scrollTop = 0;
164
172
  }
165
173
  else {
166
- // default case
174
+ // default case: select next/previous in item list
167
175
  activeOption = matchingEnabledOptions[newActiveIdx];
176
+ }
177
+ if (autoScroll) {
178
+ await tick();
168
179
  const li = document.querySelector(`ul.options > li.active`);
169
- // scrollIntoViewIfNeeded() scrolls top edge of element into view so when moving
170
- // downwards, we scroll to next sibling to make element fully visible
171
- if (increment === 1)
172
- li?.nextSibling?.scrollIntoViewIfNeeded();
173
- else
174
- li?.scrollIntoViewIfNeeded();
180
+ li?.scrollIntoViewIfNeeded();
175
181
  }
176
182
  }
183
+ // on backspace key: remove last selected option
177
184
  else if (event.key === `Backspace`) {
178
185
  const label = selectedLabels.pop();
179
186
  if (label && !searchText)
@@ -198,22 +205,27 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
198
205
  <!-- z-index: 2 when showOptions is true ensures the ul.selected of one <MultiSelect />
199
206
  display above those of another following shortly after it -->
200
207
  <div
201
- class="multiselect {outerDivClass}"
202
- class:readonly
203
- class:single={maxSelect == 1}
208
+ class:disabled
209
+ class:single={maxSelect === 1}
204
210
  class:open={showOptions}
211
+ class="multiselect {outerDivClass}"
205
212
  on:mouseup|stopPropagation={() => setOptionsVisible(true)}
206
- use:onClickOutside={() => setOptionsVisible(false)}
207
- use:onClickOutside={() => dispatch(`blur`)}
213
+ on:focusout={() => {
214
+ setOptionsVisible(false)
215
+ dispatch(`blur`)
216
+ }}
217
+ title={disabled ? disabledTitle : null}
208
218
  >
219
+ <!-- invisible input, used only to prevent form submission if required=true and no options selected -->
220
+ <input {required} bind:value={formValue} tabindex="-1" class="form-control" />
209
221
  <ExpandIcon style="min-width: 1em; padding: 0 1pt;" />
210
222
  <ul class="selected {ulSelectedClass}">
211
223
  {#each selected as option, idx}
212
224
  <li class={liSelectedClass}>
213
- <slot name="renderSelected" {option} {idx}>
225
+ <slot name="selected" {option} {idx}>
214
226
  {option.label}
215
227
  </slot>
216
- {#if !readonly}
228
+ {#if !disabled}
217
229
  <button
218
230
  on:mouseup|stopPropagation={() => remove(option.label)}
219
231
  on:keydown={handleEnterAndSpaceKeys(() => remove(option.label))}
@@ -228,58 +240,83 @@ display above those of another following shortly after it -->
228
240
  <li style="display: contents;">
229
241
  <input
230
242
  bind:this={input}
231
- autocomplete="off"
243
+ {autocomplete}
232
244
  bind:value={searchText}
233
245
  on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
234
246
  on:keydown={handleKeydown}
235
247
  on:focus={() => setOptionsVisible(true)}
248
+ on:blur={() => setOptionsVisible(false)}
236
249
  {id}
237
250
  {name}
251
+ {disabled}
238
252
  placeholder={selectedLabels.length ? `` : placeholder}
239
253
  />
240
254
  </li>
241
255
  </ul>
242
- {#if readonly}
243
- <ReadOnlyIcon height="14pt" />
256
+ {#if loading}
257
+ <slot name="spinner">
258
+ <CircleSpinner />
259
+ </slot>
260
+ {/if}
261
+ {#if disabled}
262
+ <slot name="disabled-icon">
263
+ <DisabledIcon height="14pt" />
264
+ </slot>
244
265
  {:else if selected.length > 0}
245
- {#if maxSelect !== null && maxSelectMsg !== null}
266
+ {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
246
267
  <Wiggle bind:wiggle angle={20}>
247
- <span style="padding: 0 3pt;">{maxSelectMsg(selected.length, maxSelect)}</span>
268
+ <span style="padding: 0 3pt;">
269
+ {maxSelectMsg?.(selected.length, maxSelect) ??
270
+ (maxSelect > 1 ? `${selected.length}/${maxSelect}` : ``)}
271
+ </span>
248
272
  </Wiggle>
249
273
  {/if}
250
- <button
251
- type="button"
252
- class="remove-all"
253
- title={removeAllTitle}
254
- on:mouseup|stopPropagation={removeAll}
255
- on:keydown={handleEnterAndSpaceKeys(removeAll)}
256
- >
257
- <CrossIcon height="14pt" />
258
- </button>
274
+ {#if maxSelect !== 1}
275
+ <button
276
+ type="button"
277
+ class="remove-all"
278
+ title={removeAllTitle}
279
+ on:mouseup|stopPropagation={removeAll}
280
+ on:keydown={handleEnterAndSpaceKeys(removeAll)}
281
+ >
282
+ <CrossIcon height="14pt" />
283
+ </button>
284
+ {/if}
259
285
  {/if}
260
286
 
261
287
  {#key showOptions}
262
288
  <ul
263
- class="options {ulOptionsClass}"
264
289
  class:hidden={!showOptions}
290
+ class="options {ulOptionsClass}"
265
291
  transition:fly|local={{ duration: 300, y: 40 }}
266
292
  >
267
293
  {#each matchingOptions as option, idx}
268
294
  {@const { label, disabled, title = null, selectedTitle } = option}
269
295
  {@const { disabledTitle = defaultDisabledTitle } = option}
296
+ {@const active = activeOption?.label === label}
270
297
  <li
271
298
  on:mouseup|preventDefault|stopPropagation
272
299
  on:mousedown|preventDefault|stopPropagation={() => {
273
300
  if (disabled) return
274
301
  isSelected(label) ? remove(label) : add(label)
275
302
  }}
303
+ title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
276
304
  class:selected={isSelected(label)}
277
- class:active={activeOption?.label === label}
305
+ class:active
278
306
  class:disabled
279
- title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
280
- class={liOptionClass}
307
+ class="{liOptionClass} {active ? liActiveOptionClass : ``}"
308
+ on:mouseover={() => {
309
+ if (disabled) return
310
+ activeOption = option
311
+ }}
312
+ on:focus={() => {
313
+ if (disabled) return
314
+ activeOption = option
315
+ }}
316
+ on:mouseout={() => (activeOption = null)}
317
+ on:blur={() => (activeOption = null)}
281
318
  >
282
- <slot name="renderOptions" {option} {idx}>
319
+ <slot name="option" {option} {idx}>
283
320
  {option.label}
284
321
  </slot>
285
322
  </li>
@@ -299,9 +336,11 @@ display above those of another following shortly after it -->
299
336
  cursor: text;
300
337
  padding: 0 3pt;
301
338
  border: var(--sms-border, 1pt solid lightgray);
302
- border-radius: var(--sms-border-radius, 5pt);
339
+ border-radius: var(--sms-border-radius, 3pt);
303
340
  background: var(--sms-input-bg);
304
341
  min-height: var(--sms-input-min-height, 22pt);
342
+ color: var(--sms-text-color);
343
+ font-size: var(--sms-font-size, inherit);
305
344
  }
306
345
  :where(div.multiselect.open) {
307
346
  z-index: var(--sms-open-z-index, 4);
@@ -309,8 +348,9 @@ display above those of another following shortly after it -->
309
348
  :where(div.multiselect:focus-within) {
310
349
  border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
311
350
  }
312
- :where(div.multiselect.readonly) {
313
- background: var(--sms-readonly-bg, lightgray);
351
+ :where(div.multiselect.disabled) {
352
+ background: var(--sms-disabled-bg, lightgray);
353
+ cursor: not-allowed;
314
354
  }
315
355
 
316
356
  :where(div.multiselect > ul.selected) {
@@ -326,26 +366,24 @@ display above those of another following shortly after it -->
326
366
  display: flex;
327
367
  margin: 2pt;
328
368
  line-height: normal;
329
- padding: 1pt 2pt 1pt 5pt;
369
+ padding: 1pt 5pt;
330
370
  transition: 0.3s;
331
371
  white-space: nowrap;
332
- background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue));
372
+ background: var(--sms-selected-bg, rgba(0, 0, 0, 0.15));
333
373
  height: var(--sms-selected-li-height);
374
+ color: var(--sms-selected-text-color, var(--sms-text-color));
334
375
  }
335
- :where(div.multiselect > ul.selected > li button, button.remove-all) {
336
- align-items: center;
376
+ :where(div.multiselect button) {
337
377
  border-radius: 50%;
338
378
  display: flex;
339
- cursor: pointer;
340
379
  transition: 0.2s;
341
- }
342
- :where(div.multiselect button) {
343
380
  color: inherit;
344
381
  background: transparent;
345
382
  border: none;
346
383
  cursor: pointer;
347
384
  outline: none;
348
- padding: 0 2pt;
385
+ padding: 0;
386
+ margin: 0 0 0 4pt; /* CSS reset */
349
387
  }
350
388
  :where(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
351
389
  color: var(--sms-remove-x-hover-focus-color, lightskyblue);
@@ -354,29 +392,44 @@ display above those of another following shortly after it -->
354
392
  transform: scale(1.04);
355
393
  }
356
394
 
395
+ :where(div.multiselect input) {
396
+ margin: auto 0; /* CSS reset */
397
+ padding: 0; /* CSS reset */
398
+ }
357
399
  :where(div.multiselect > ul.selected > li > input) {
358
400
  border: none;
359
401
  outline: none;
360
402
  background: none;
361
403
  flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
362
404
  min-width: 2em;
363
- /* minimum font-size > 16px ensures iOS doesn't zoom in when focusing input */
364
- /* https://stackoverflow.com/a/6394497 */
365
- font-size: calc(16px + 0.1vw);
366
- color: var(--sms-text-color, inherit);
405
+ color: inherit;
406
+ font-size: inherit;
407
+ cursor: inherit; /* needed for disabled state */
408
+ }
409
+ :where(div.multiselect > input.form-control) {
410
+ width: 2em;
411
+ position: absolute;
412
+ background: transparent;
413
+ border: none;
414
+ outline: none;
415
+ z-index: -1;
416
+ opacity: 0;
417
+ pointer-events: none;
367
418
  }
368
419
 
369
420
  :where(div.multiselect > ul.options) {
370
421
  list-style: none;
371
- max-height: 50vh;
372
422
  padding: 0;
373
423
  top: 100%;
424
+ left: 0;
374
425
  width: 100%;
375
426
  position: absolute;
376
427
  border-radius: 1ex;
377
428
  overflow: auto;
378
429
  background: var(--sms-options-bg, white);
430
+ max-height: var(--sms-options-max-height, 50vh);
379
431
  overscroll-behavior: var(--sms-options-overscroll, none);
432
+ box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);
380
433
  }
381
434
  :where(div.multiselect > ul.options.hidden) {
382
435
  visibility: hidden;
@@ -384,34 +437,22 @@ display above those of another following shortly after it -->
384
437
  :where(div.multiselect > ul.options > li) {
385
438
  padding: 3pt 2ex;
386
439
  cursor: pointer;
440
+ scroll-margin: var(--sms-options-scroll-margin, 100px);
387
441
  }
388
442
  /* for noOptionsMsg */
389
443
  :where(div.multiselect > ul.options span) {
390
444
  padding: 3pt 2ex;
391
445
  }
392
446
  :where(div.multiselect > ul.options > li.selected) {
393
- border-left: var(
394
- --sms-li-selected-border-left,
395
- 3pt solid var(--sms-selected-color, green)
396
- );
397
- background: var(--sms-li-selected-bg, inherit);
398
- color: var(--sms-li-selected-color, inherit);
399
- }
400
- :where(div.multiselect > ul.options > li:not(.selected):hover) {
401
- border-left: var(
402
- --sms-li-not-selected-hover-border-left,
403
- 3pt solid var(--sms-active-color, cornflowerblue)
404
- );
447
+ background: var(--sms-li-selected-bg);
448
+ color: var(--sms-li-selected-color);
405
449
  }
406
450
  :where(div.multiselect > ul.options > li.active) {
407
- background: var(--sms-li-active-bg, var(--sms-active-color, cornflowerblue));
451
+ background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
408
452
  }
409
453
  :where(div.multiselect > ul.options > li.disabled) {
410
454
  cursor: not-allowed;
411
455
  background: var(--sms-li-disabled-bg, #f5f5f6);
412
456
  color: var(--sms-li-disabled-text, #b8b8b8);
413
457
  }
414
- :where(div.multiselect > ul.options > li.disabled:hover) {
415
- border-left: unset;
416
- }
417
458
  </style>
@@ -5,9 +5,12 @@ declare const __propDef: {
5
5
  selected?: Option[] | undefined;
6
6
  selectedLabels?: Primitive[] | undefined;
7
7
  selectedValues?: Primitive[] | undefined;
8
+ searchText?: string | undefined;
9
+ showOptions?: boolean | undefined;
8
10
  maxSelect?: number | null | undefined;
9
- maxSelectMsg?: ((current: number, max: number) => string) | undefined;
10
- readonly?: boolean | undefined;
11
+ maxSelectMsg?: ((current: number, max: number) => string) | null | undefined;
12
+ disabled?: boolean | undefined;
13
+ disabledTitle?: string | undefined;
11
14
  options: ProtoOption[];
12
15
  input?: HTMLInputElement | null | undefined;
13
16
  placeholder?: string | undefined;
@@ -21,9 +24,15 @@ declare const __propDef: {
21
24
  liSelectedClass?: string | undefined;
22
25
  ulOptionsClass?: string | undefined;
23
26
  liOptionClass?: string | undefined;
27
+ liActiveOptionClass?: string | undefined;
24
28
  removeBtnTitle?: string | undefined;
25
29
  removeAllTitle?: string | undefined;
26
30
  defaultDisabledTitle?: string | undefined;
31
+ allowUserOptions?: boolean | "append" | undefined;
32
+ autoScroll?: boolean | undefined;
33
+ loading?: boolean | undefined;
34
+ required?: boolean | undefined;
35
+ autocomplete?: string | undefined;
27
36
  };
28
37
  events: {
29
38
  mouseup: MouseEvent;
@@ -31,11 +40,13 @@ declare const __propDef: {
31
40
  [evt: string]: CustomEvent<any>;
32
41
  };
33
42
  slots: {
34
- renderSelected: {
43
+ selected: {
35
44
  option: Option;
36
45
  idx: any;
37
46
  };
38
- renderOptions: {
47
+ spinner: {};
48
+ 'disabled-icon': {};
49
+ option: {
39
50
  option: Option;
40
51
  idx: any;
41
52
  };
File without changes
@@ -10,9 +10,9 @@ declare const __propDef: {
10
10
  };
11
11
  slots: {};
12
12
  };
13
- export declare type ReadOnlyProps = typeof __propDef.props;
14
- export declare type ReadOnlyEvents = typeof __propDef.events;
15
- export declare type ReadOnlySlots = typeof __propDef.slots;
16
- export default class ReadOnly extends SvelteComponentTyped<ReadOnlyProps, ReadOnlyEvents, ReadOnlySlots> {
13
+ export declare type DisabledProps = typeof __propDef.props;
14
+ export declare type DisabledEvents = typeof __propDef.events;
15
+ export declare type DisabledSlots = typeof __propDef.slots;
16
+ export default class Disabled extends SvelteComponentTyped<DisabledProps, DisabledEvents, DisabledSlots> {
17
17
  }
18
18
  export {};
package/icons/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { default as CrossIcon } from './Cross.svelte';
2
1
  export { default as ExpandIcon } from './ChevronExpand.svelte';
3
- export { default as ReadOnlyIcon } from './ReadOnly.svelte';
2
+ export { default as CrossIcon } from './Cross.svelte';
3
+ export { default as DisabledIcon } from './Disabled.svelte';
package/icons/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { default as CrossIcon } from './Cross.svelte';
2
1
  export { default as ExpandIcon } from './ChevronExpand.svelte';
3
- export { default as ReadOnlyIcon } from './ReadOnly.svelte';
2
+ export { default as CrossIcon } from './Cross.svelte';
3
+ export { default as DisabledIcon } from './Disabled.svelte';
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,32 +5,37 @@
5
5
  "homepage": "https://svelte-multiselect.netlify.app",
6
6
  "repository": "https://github.com/janosh/svelte-multiselect",
7
7
  "license": "MIT",
8
- "version": "3.2.3",
8
+ "version": "4.0.1",
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.28",
14
- "@sveltejs/kit": "^1.0.0-next.269",
15
- "@typescript-eslint/eslint-plugin": "^5.12.0",
14
+ "@sveltejs/kit": "^1.0.0-next.287",
15
+ "@sveltejs/vite-plugin-svelte": "^1.0.0-next.38",
16
+ "@testing-library/svelte": "^3.0.3",
17
+ "@typescript-eslint/eslint-plugin": "^5.12.1",
16
18
  "@typescript-eslint/parser": "^5.12.0",
19
+ "@vitest/ui": "^0.5.7",
17
20
  "eslint": "^8.9.0",
18
- "eslint-plugin-svelte3": "^3.4.0",
21
+ "eslint-plugin-svelte3": "^3.4.1",
19
22
  "hastscript": "^7.0.2",
23
+ "jsdom": "^19.0.0",
20
24
  "mdsvex": "^0.10.5",
21
25
  "prettier": "^2.5.1",
22
26
  "prettier-plugin-svelte": "^2.6.0",
23
27
  "rehype-autolink-headings": "^6.1.1",
24
28
  "rehype-slug": "^5.0.1",
25
29
  "svelte": "^3.46.4",
26
- "svelte-check": "^2.4.3",
30
+ "svelte-check": "^2.4.5",
27
31
  "svelte-github-corner": "^0.1.0",
28
- "svelte-preprocess": "^4.10.3",
29
- "svelte-toc": "^0.2.5",
30
- "svelte2tsx": "^0.5.3",
32
+ "svelte-preprocess": "^4.10.4",
33
+ "svelte-toc": "^0.2.6",
34
+ "svelte2tsx": "^0.5.5",
31
35
  "tslib": "^2.3.1",
32
36
  "typescript": "^4.5.5",
33
- "vite": "^2.8.2"
37
+ "vite": "^2.8.4",
38
+ "vitest": "^0.5.7"
34
39
  },
35
40
  "keywords": [
36
41
  "svelte",
package/readme.md CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  <h4 align="center">
7
7
 
8
+ [![Tests](https://github.com/janosh/svelte-multiselect/actions/workflows/test.yml/badge.svg)](https://github.com/janosh/svelte-multiselect/actions/workflows/test.yml)
8
9
  [![Netlify Status](https://api.netlify.com/api/v1/badges/a45b62c3-ea45-4cfd-9912-77ec4fc8d7e8/deploy-status)](https://app.netlify.com/sites/svelte-multiselect/deploys)
9
10
  [![NPM version](https://img.shields.io/npm/v/svelte-multiselect?color=blue&logo=NPM)](https://npmjs.com/package/svelte-multiselect)
10
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)
@@ -35,16 +36,22 @@
35
36
 
36
37
  ## Recent breaking changes
37
38
 
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
39
  - v3.0.0 changed the `event.detail` payload for `'add'`, `'remove'` and `'change'` events from `token` to `option`, e.g.
40
40
 
41
41
  ```js
42
- on:add={(e) => console.log(e.detail.token.label)} // v2.0.0
43
- on:add={(e) => console.log(e.detail.option.label)} // v3.0.0
42
+ on:add={(e) => console.log(e.detail.token.label)} // v2
43
+ on:add={(e) => console.log(e.detail.option.label)} // v3
44
44
  ```
45
45
 
46
46
  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
47
 
48
+ - v4.0.0 renamed the slots for customizing how selected options and dropdown list items are rendered:
49
+
50
+ - old: `<slot name="renderOptions" />`, new: `<slot name="option" />`
51
+ - old: `<slot name="renderSelected" />`, new: `<slot name="selected" />`
52
+
53
+ - v4.0.1 renamed the `readonly` prop to `disabled` which now prevents all form or user interaction with this component including opening the dropdown list which was still possible before. See [#45](https://github.com/janosh/svelte-multiselect/issues/45) for details. The associated CSS class applied to the outer `div` was likewise renamed to `div.multiselect.{readonly=>disabled}`.
54
+
48
55
  ## Installation
49
56
 
50
57
  ```sh
@@ -71,7 +78,7 @@ yarn add -D svelte-multiselect
71
78
  `Spring`,
72
79
  ]
73
80
 
74
- let selected
81
+ let selected = []
75
82
  </script>
76
83
 
77
84
  Favorite Web Frameworks?
@@ -88,19 +95,31 @@ Full list of props/bindable variables for this component:
88
95
  <div class="table">
89
96
 
90
97
  <!-- 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
- | `activeOption` | `null` | Currently active option, i.e. the one the user currently hovers or navigated to with arrow keys. |
95
- | `maxSelect` | `null` | Positive integer to limit the number of options users can pick. `null` means no limit. |
96
- | `selected` | `[]` | Array of currently/pre-selected options when binding/passing as props respectively. |
97
- | `selectedLabels` | `[]` | Labels of currently selected options. |
98
- | `selectedValues` | `[]` | Values of currently selected options. |
99
- | `readonly` | `false` | Disable the component. It will still be rendered but users won't be able to interact with it. |
100
- | `placeholder` | `undefined` | String shown in the text input when no option is selected. |
101
- | `input` | `undefined` | Handle to the `<input>` DOM node. |
102
- | `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. |
103
- | `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>`. |
98
+ | name | default | description |
99
+ | :--------------------- | :-------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `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. |
101
+ | `showOptions` | `false` | Bindable boolean that controls whether the options dropdown is visible. |
102
+ | `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. |
103
+ | `activeOption` | `null` | Currently active option, i.e. the one the user currently hovers or navigated to with arrow keys. |
104
+ | `maxSelect` | `null` | Positive integer to limit the number of options users can pick. `null` means no limit. |
105
+ | `selected` | `[]` | Array of currently/pre-selected options when binding/passing as props respectively. |
106
+ | `selectedLabels` | `[]` | Labels of currently selected options. |
107
+ | `selectedValues` | `[]` | Values of currently selected options. |
108
+ | `noOptionsMsg` | `'No matching options'` | What message to show if no options match the user-entered search string. |
109
+ | `disabled` | `false` | Disable the component. It will still be rendered but users won't be able to interact with it. |
110
+ | `disabledTitle` | `This field is disabled` | Tooltip text to display on hover when the component is in `disabled` state. |
111
+ | `placeholder` | `undefined` | String shown in the text input when no option is selected. |
112
+ | `input` | `undefined` | Handle to the `<input>` DOM node. |
113
+ | `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. |
114
+ | `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>`. |
115
+ | `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. |
116
+ | `autoScroll` | `true` | `false` disables keeping the active dropdown items in view when going up/down the list of options with arrow keys. |
117
+ | `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. |
118
+ | `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. |
119
+ | `removeBtnTitle` | `'Remove'` | Title text to display when user hovers over button (cross icon) to remove selected option. |
120
+ | `removeAllTitle` | `'Remove all'` | Title text to display when user hovers over remove-all button. |
121
+ | `defaultDisabledTitle` | `'This option is disabled'` | Title text to display when user hovers over a disabled option. Each option can override this through its `disabledTitle` attribute. |
122
+ | `autocomplete` | `'off'` | Applied to the `<input>`. Specifies if browser is permitted to auto-fill this form field. See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for other admissible values. |
104
123
 
105
124
  </div>
106
125
 
@@ -115,7 +134,7 @@ Full list of props/bindable variables for this component:
115
134
  }
116
135
  ```
117
136
 
118
- 2. `maxSelectMsg = (current: number, max: number) => string`: Inform the user how many of the maximum allowed options they have currently selected. Return empty string to disable, i.e. `() => ''`. Is automatically disabled when `maxSelect === null`. Defaults to:
137
+ 2. `maxSelectMsg = (current: number, max: number) => string`: Inform users how many of the maximum allowed options they have already selected. Set `maxSelectMsg={null}` to not show a message. Defaults to `null` when `maxSelect={1}` or `maxSelect={null}`. Else if `maxSelect > 1`, defaults to:
119
138
 
120
139
  ```ts
121
140
  maxSelectMsg = (current: number, max: number) => `${current}/${max}`
@@ -123,24 +142,28 @@ Full list of props/bindable variables for this component:
123
142
 
124
143
  ## Slots
125
144
 
126
- `MultiSelect.svelte` accepts two named slots
145
+ `MultiSelect.svelte` has 3 named slots:
127
146
 
128
- - `slot="renderOptions"`
129
- - `slot="renderSelected"`
147
+ - `slot="option"`: Customize rendering of dropdown options. Receives as props the `option` object and the zero-indexed position (`idx`) it has in the dropdown.
148
+ - `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.
149
+ - `slot="spinner"`: Custom spinner component to display when in `loading` state. Receives no props.
150
+ - `slot="disabled-icon"`: Custom icon to display inside the input when in `disabled` state. Receives no props. Use an empty `<span slot="disabled-icon" />` or `div` to remove the default disabled icon.
130
151
 
131
- 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:
152
+ Example:
132
153
 
133
154
  ```svelte
134
155
  <MultiSelect options={[`Banana`, `Apple`, `Mango`]}>
135
- <span let:idx let:option slot="renderOptions">
156
+ <span let:idx let:option slot="option">
136
157
  {idx + 1}. {option.label}
137
158
  {option.label === `Mango` ? `🎉` : ``}
138
159
  </span>
139
160
 
140
- <span let:idx let:option slot="renderSelected">
161
+ <span let:idx let:option slot="selected">
141
162
  #{idx + 1}
142
163
  {option.label}
143
164
  </span>
165
+
166
+ <CustomSpinner slot="spinner">
144
167
  </MultiSelect>
145
168
  ```
146
169
 
@@ -212,33 +235,34 @@ If you only want to make small adjustments, you can pass the following CSS varia
212
235
 
213
236
  - `div.multiselect`
214
237
  - `border: var(--sms-border, 1pt solid lightgray)`: Change this to e.g. to `1px solid red` to indicate this form field is in an invalid state.
215
- - `border-radius: var(--sms-border-radius, 5pt)`
238
+ - `border-radius: var(--sms-border-radius, 3pt)`
216
239
  - `background: var(--sms-input-bg)`
217
240
  - `height: var(--sms-input-height, 2em)`
241
+ - `color: var(--sms-text-color)`
218
242
  - `div.multiselect.open`
219
243
  - `z-index: var(--sms-open-z-index, 4)`: Increase this if needed to ensure the dropdown list is displayed atop all other page elements.
220
244
  - `div.multiselect:focus-within`
221
245
  - `border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue))`: Border when component has focus. Defaults to `--sms-active-color` if not set which defaults to `cornflowerblue`.
222
- - `div.multiselect.readonly`
223
- - `background: var(--sms-readonly-bg, lightgray)`: Background when in readonly state.
224
- - `div.multiselect > ul.selected > li > input`
225
- - `color: var(--sms-text-color, inherit)`: Input text color.
246
+ - `div.multiselect.disabled`
247
+ - `background: var(--sms-disabled-bg, lightgray)`: Background when in disabled state.
226
248
  - `div.multiselect > ul.selected > li`
227
- - `background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue))`: Background of selected options.
249
+ - `background: var(--sms-selected-bg, rgba(0, 0, 0, 0.15))`: Background of selected options.
228
250
  - `height: var(--sms-selected-li-height)`: Height of selected options.
251
+ - `color: var(--sms-selected-text-color, var(--sms-text-color))`: Text color for selected options.
229
252
  - `ul.selected > li button:hover, button.remove-all:hover, button:focus`
230
253
  - `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.
231
254
  - `div.multiselect > ul.options`
232
255
  - `background: var(--sms-options-bg, white)`: Background of dropdown list.
256
+ - `max-height: var(--sms-options-max-height, 50vh)`: Maximum height of options dropdown.
233
257
  - `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).
258
+ - `box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);`: Box shadow of dropdown list.
259
+ - `div.multiselect > ul.options > li`
260
+ - `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.
234
261
  - `div.multiselect > ul.options > li.selected`
235
- - `border-left: var(--sms-li-selected-border-left, 3pt solid var(--sms-selected-color, green))`
236
- - `background: var(--sms-li-selected-bg, inherit)`: Background of selected list items in options pane.
237
- - `color: var(--sms-li-selected-color, inherit)`: Text color of selected list items in options pane.
238
- - `div.multiselect > ul.options > li:not(.selected):hover`
239
- - `border-left: var(--sms-li-not-selected-hover-border-left, 3pt solid var(--sms-active-color, cornflowerblue))`
262
+ - `background: var(--sms-li-selected-bg)`: Background of selected list items in options pane.
263
+ - `color: var(--sms-li-selected-color)`: Text color of selected list items in options pane.
240
264
  - `div.multiselect > ul.options > li.active`
241
- - `background: var(--sms-li-active-bg, var(--sms-active-color, cornflowerblue))`: Background of active (currently with arrow keys highlighted) list item.
265
+ - `background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)))`: Background of active dropdown item. Items become active either by mouseover or by navigating to them with arrow keys.
242
266
  - `div.multiselect > ul.options > li.disabled`
243
267
  - `background: var(--sms-li-disabled-bg, #f5f5f6)`: Background of disabled options in the dropdown list.
244
268
  - `color: var(--sms-li-disabled-text, #b8b8b8)`: Text color of disabled option in the dropdown list.
@@ -258,6 +282,7 @@ The second method allows you to pass in custom classes to the important DOM elem
258
282
  - `liSelectedClass`
259
283
  - `ulOptionsClass`
260
284
  - `liOptionClass`
285
+ - `liActiveOptionClass`
261
286
 
262
287
  This simplified version of the DOM structure of this component shows where these classes are inserted:
263
288
 
@@ -269,7 +294,9 @@ This simplified version of the DOM structure of this component shows where these
269
294
  </ul>
270
295
  <ul class="options {ulOptionsClass}">
271
296
  <li class={liOptionClass}>Option 1</li>
272
- <li class={liOptionClass}>Option 2</li>
297
+ <li class="{liOptionClass} {liActiveOptionClass}">
298
+ Option 2 (currently active)
299
+ </li>
273
300
  </ul>
274
301
  </div>
275
302
  ```
@@ -285,8 +312,8 @@ You can alternatively style every part of this component with more fine-grained
285
312
  :global(div.multiselect.open) {
286
313
  /* top-level wrapper div when dropdown open */
287
314
  }
288
- :global(div.multiselect.readonly) {
289
- /* top-level wrapper div when in readonly state */
315
+ :global(div.multiselect.disabled) {
316
+ /* top-level wrapper div when in disabled state */
290
317
  }
291
318
  :global(div.multiselect > ul.selected) {
292
319
  /* selected list */
@@ -324,6 +351,40 @@ You can alternatively style every part of this component with more fine-grained
324
351
  }
325
352
  ```
326
353
 
354
+ ## Downstream testing
355
+
356
+ To test a Svelte component which imports `svelte-multiselect`, you need to configure your test runner to avoid [transpiling issues](https://github.com/EmilTholin/svelte-routing/issues/140#issuecomment-661682571).
357
+
358
+ For Jest, exclude `svelte-multiselect` from `transformIgnorePatterns` in your `jest.config.json`:
359
+
360
+ ```json
361
+ {
362
+ "transformIgnorePatterns": ["node_modules/?!(svelte-multiselect)"],
363
+ "transform": {
364
+ "^.+\\.[t|j]s?$": "esbuild-jest",
365
+ "^.+\\.svelte$": ["svelte-jester", { "preprocess": true }]
366
+ }
367
+ }
368
+ ```
369
+
370
+ For Vitest, include `svelte-multiselect` in `deps.inline`:
371
+
372
+ ```ts
373
+ // vite.config.ts
374
+ import { svelte } from '@sveltejs/vite-plugin-svelte'
375
+
376
+ export default {
377
+ plugins: [svelte({ hot: !process.env.VITEST })],
378
+ test: {
379
+ deps: {
380
+ inline: [/svelte-multiselect/],
381
+ },
382
+ },
383
+ }
384
+ ```
385
+
386
+ 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).
387
+
327
388
  ## Want to contribute?
328
389
 
329
390
  To submit a PR, clone the repo, install dependencies and start the dev server to try out your changes.
package/actions.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export declare function onClickOutside(node: HTMLElement, cb?: () => void): {
2
- destroy(): void;
3
- };
package/actions.js DELETED
@@ -1,16 +0,0 @@
1
- export function onClickOutside(node, cb) {
2
- const dispatchOnClickOutside = (event) => {
3
- const clickWasOutside = node && !node.contains(event.target);
4
- if (clickWasOutside && !event.defaultPrevented) {
5
- node.dispatchEvent(new CustomEvent(`clickOutside`));
6
- if (cb)
7
- cb();
8
- }
9
- };
10
- document.addEventListener(`click`, dispatchOnClickOutside);
11
- return {
12
- destroy() {
13
- document.removeEventListener(`click`, dispatchOnClickOutside);
14
- },
15
- };
16
- }