svelte-multiselect 3.2.1 → 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,13 +1,16 @@
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 = [];
7
8
  export let selectedLabels = [];
8
9
  export let selectedValues = [];
10
+ export let searchText = ``;
11
+ export let showOptions = false;
9
12
  export let maxSelect = null; // null means any number of options are selectable
10
- export let maxSelectMsg = (current, max) => `${current}/${max}`;
13
+ export let maxSelectMsg = null;
11
14
  export let readonly = false;
12
15
  export let options;
13
16
  export let input = null;
@@ -16,15 +19,25 @@ export let id = undefined;
16
19
  export let name = id;
17
20
  export let noOptionsMsg = `No matching options`;
18
21
  export let activeOption = null;
22
+ export let filterFunc = (op, searchText) => {
23
+ if (!searchText)
24
+ return true;
25
+ return `${op.label}`.toLowerCase().includes(searchText.toLowerCase());
26
+ };
19
27
  export let outerDivClass = ``;
20
28
  export let ulSelectedClass = ``;
21
29
  export let liSelectedClass = ``;
22
30
  export let ulOptionsClass = ``;
23
31
  export let liOptionClass = ``;
32
+ export let liActiveOptionClass = ``;
24
33
  export let removeBtnTitle = `Remove`;
25
34
  export let removeAllTitle = `Remove all`;
26
35
  // https://github.com/sveltejs/svelte/issues/6964
27
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;
28
41
  if (maxSelect !== null && maxSelect < 0) {
29
42
  console.error(`maxSelect must be null or positive integer, got ${maxSelect}`);
30
43
  }
@@ -36,6 +49,10 @@ onMount(() => {
36
49
  selected = _options.filter((op) => op?.preselected);
37
50
  });
38
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();
39
56
  function isObject(item) {
40
57
  return typeof item === `object` && !Array.isArray(item) && item !== null;
41
58
  }
@@ -62,31 +79,16 @@ $: if (new Set(labels).size !== options.length) {
62
79
  }
63
80
  $: selectedLabels = selected.map((op) => op.label);
64
81
  $: selectedValues = selected.map((op) => op.value);
65
- const dispatch = createEventDispatcher();
66
- let searchText = ``;
67
- let showOptions = false;
68
82
  // options matching the current search text
69
- $: matchingOptions = _options.filter((op) => {
70
- if (!searchText)
71
- return true;
72
- return `${op.label}`.toLowerCase().includes(searchText.toLowerCase());
73
- });
83
+ $: matchingOptions = _options.filter((op) => filterFunc(op, searchText) && !selectedLabels.includes(op.label));
74
84
  $: matchingEnabledOptions = matchingOptions.filter((op) => !op.disabled);
75
- $: if (
76
- // if there was an active option but it's not in the filtered list of options
77
- (activeOption &&
78
- !matchingEnabledOptions.map((op) => op.label).includes(activeOption.label)) ||
79
- // or there's no active option but the user entered search text
80
- (!activeOption && searchText))
81
- // make the first filtered option active
82
- activeOption = matchingEnabledOptions[0];
83
85
  function add(label) {
84
- if (selected.length - (maxSelect ?? 0) < 1)
86
+ if (maxSelect && maxSelect > 1 && selected.length >= maxSelect)
85
87
  wiggle = true;
86
88
  if (!readonly &&
87
89
  !selectedLabels.includes(label) &&
88
90
  // for maxselect = 1 we always replace current option with new selection
89
- (maxSelect == null || maxSelect == 1 || selected.length < maxSelect)) {
91
+ (maxSelect === null || maxSelect === 1 || selected.length < maxSelect)) {
90
92
  searchText = ``; // reset search string on selection
91
93
  const option = _options.find((op) => op.label === label);
92
94
  if (!option) {
@@ -108,15 +110,15 @@ function add(label) {
108
110
  function remove(label) {
109
111
  if (selected.length === 0 || readonly)
110
112
  return;
111
- selected = selected.filter((option) => label !== option.label);
112
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);
113
118
  dispatch(`remove`, { option });
114
119
  dispatch(`change`, { option, type: `remove` });
115
120
  }
116
121
  function setOptionsVisible(show) {
117
- // nothing to do if visibility is already as intended
118
- if (readonly || show === showOptions)
119
- return;
120
122
  showOptions = show;
121
123
  if (show)
122
124
  input?.focus();
@@ -126,7 +128,7 @@ function setOptionsVisible(show) {
126
128
  }
127
129
  }
128
130
  // handle all keyboard events this component receives
129
- function handleKeydown(event) {
131
+ async function handleKeydown(event) {
130
132
  // on escape: dismiss options dropdown and reset search text
131
133
  if (event.key === `Escape`) {
132
134
  setOptionsVisible(false);
@@ -138,7 +140,15 @@ function handleKeydown(event) {
138
140
  const { label } = activeOption;
139
141
  selectedLabels.includes(label) ? remove(label) : add(label);
140
142
  searchText = ``;
141
- } // 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
142
152
  else
143
153
  setOptionsVisible(true);
144
154
  }
@@ -151,31 +161,25 @@ function handleKeydown(event) {
151
161
  }
152
162
  const increment = event.key === `ArrowUp` ? -1 : 1;
153
163
  const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
154
- const ulOps = document.querySelector(`ul.options`);
155
164
  if (newActiveIdx < 0) {
156
165
  // wrap around top
157
166
  activeOption = matchingEnabledOptions[matchingEnabledOptions.length - 1];
158
- if (ulOps)
159
- ulOps.scrollTop = ulOps.scrollHeight;
160
167
  }
161
168
  else if (newActiveIdx === matchingEnabledOptions.length) {
162
169
  // wrap around bottom
163
170
  activeOption = matchingEnabledOptions[0];
164
- if (ulOps)
165
- ulOps.scrollTop = 0;
166
171
  }
167
172
  else {
168
- // default case
173
+ // default case: select next/previous in item list
169
174
  activeOption = matchingEnabledOptions[newActiveIdx];
175
+ }
176
+ if (autoScroll) {
177
+ await tick();
170
178
  const li = document.querySelector(`ul.options > li.active`);
171
- // scrollIntoViewIfNeeded() scrolls top edge of element into view so when moving
172
- // downwards, we scroll to next sibling to make element fully visible
173
- if (increment === 1)
174
- li?.nextSibling?.scrollIntoViewIfNeeded();
175
- else
176
- li?.scrollIntoViewIfNeeded();
179
+ li?.scrollIntoViewIfNeeded();
177
180
  }
178
181
  }
182
+ // on backspace key: remove last selected option
179
183
  else if (event.key === `Backspace`) {
180
184
  const label = selectedLabels.pop();
181
185
  if (label && !searchText)
@@ -200,19 +204,21 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
200
204
  <!-- z-index: 2 when showOptions is true ensures the ul.selected of one <MultiSelect />
201
205
  display above those of another following shortly after it -->
202
206
  <div
203
- class="multiselect {outerDivClass}"
204
207
  class:readonly
205
- class:single={maxSelect == 1}
208
+ class:single={maxSelect === 1}
206
209
  class:open={showOptions}
210
+ class="multiselect {outerDivClass}"
207
211
  on:mouseup|stopPropagation={() => setOptionsVisible(true)}
208
212
  use:onClickOutside={() => setOptionsVisible(false)}
209
213
  use:onClickOutside={() => dispatch(`blur`)}
210
214
  >
211
- <ExpandIcon height="14pt" style="padding: 0 3pt 0 1pt;" />
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" />
217
+ <ExpandIcon style="min-width: 1em; padding: 0 1pt;" />
212
218
  <ul class="selected {ulSelectedClass}">
213
219
  {#each selected as option, idx}
214
220
  <li class={liSelectedClass}>
215
- <slot name="renderSelected" {option} {idx}>
221
+ <slot name="selected" {option} {idx}>
216
222
  {option.label}
217
223
  </slot>
218
224
  {#if !readonly}
@@ -227,59 +233,72 @@ display above those of another following shortly after it -->
227
233
  {/if}
228
234
  </li>
229
235
  {/each}
236
+ <li style="display: contents;">
237
+ <input
238
+ bind:this={input}
239
+ autocomplete="off"
240
+ bind:value={searchText}
241
+ on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
242
+ on:keydown={handleKeydown}
243
+ on:focus={() => setOptionsVisible(true)}
244
+ {id}
245
+ {name}
246
+ placeholder={selectedLabels.length ? `` : placeholder}
247
+ />
248
+ </li>
230
249
  </ul>
231
- <input
232
- bind:this={input}
233
- autocomplete="off"
234
- bind:value={searchText}
235
- on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
236
- on:keydown={handleKeydown}
237
- on:focus={() => setOptionsVisible(true)}
238
- {id}
239
- {name}
240
- placeholder={selectedLabels.length ? `` : placeholder}
241
- />
250
+ {#if loading}
251
+ <slot name="spinner">
252
+ <CircleSpinner />
253
+ </slot>
254
+ {/if}
242
255
  {#if readonly}
243
256
  <ReadOnlyIcon height="14pt" />
244
257
  {:else if selected.length > 0}
245
- {#if maxSelect !== null && maxSelectMsg !== null}
258
+ {#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
246
259
  <Wiggle bind:wiggle angle={20}>
247
- <span style="padding: 0 3pt;">{maxSelectMsg(selected.length, maxSelect)}</span>
260
+ <span style="padding: 0 3pt;">
261
+ {maxSelectMsg?.(selected.length, maxSelect) ??
262
+ (maxSelect > 1 ? `${selected.length}/${maxSelect}` : ``)}
263
+ </span>
248
264
  </Wiggle>
249
265
  {/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>
266
+ {#if maxSelect !== 1}
267
+ <button
268
+ type="button"
269
+ class="remove-all"
270
+ title={removeAllTitle}
271
+ on:mouseup|stopPropagation={removeAll}
272
+ on:keydown={handleEnterAndSpaceKeys(removeAll)}
273
+ >
274
+ <CrossIcon height="14pt" />
275
+ </button>
276
+ {/if}
259
277
  {/if}
260
278
 
261
279
  {#key showOptions}
262
280
  <ul
263
- class="options {ulOptionsClass}"
264
281
  class:hidden={!showOptions}
282
+ class="options {ulOptionsClass}"
265
283
  transition:fly|local={{ duration: 300, y: 40 }}
266
284
  >
267
285
  {#each matchingOptions as option, idx}
268
286
  {@const { label, disabled, title = null, selectedTitle } = option}
269
287
  {@const { disabledTitle = defaultDisabledTitle } = option}
288
+ {@const active = activeOption?.label === label}
270
289
  <li
271
290
  on:mouseup|preventDefault|stopPropagation
272
291
  on:mousedown|preventDefault|stopPropagation={() => {
273
292
  if (disabled) return
274
293
  isSelected(label) ? remove(label) : add(label)
275
294
  }}
295
+ title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
276
296
  class:selected={isSelected(label)}
277
- class:active={activeOption?.label === label}
297
+ class:active
278
298
  class:disabled
279
- title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
280
- class={liOptionClass}
299
+ class="{liOptionClass} {active ? liActiveOptionClass : ``}"
281
300
  >
282
- <slot name="renderOptions" {option} {idx}>
301
+ <slot name="option" {option} {idx}>
283
302
  {option.label}
284
303
  </slot>
285
304
  </li>
@@ -294,15 +313,14 @@ display above those of another following shortly after it -->
294
313
  :where(div.multiselect) {
295
314
  position: relative;
296
315
  margin: 1em 0;
297
- border: var(--sms-border, 1pt solid lightgray);
298
- border-radius: var(--sms-border-radius, 5pt);
299
- background: var(--sms-input-bg);
300
- height: var(--sms-input-height, 2em);
301
316
  align-items: center;
302
- min-height: 18pt;
303
317
  display: flex;
304
318
  cursor: text;
305
319
  padding: 0 3pt;
320
+ border: var(--sms-border, 1pt solid lightgray);
321
+ border-radius: var(--sms-border-radius, 5pt);
322
+ background: var(--sms-input-bg);
323
+ min-height: var(--sms-input-min-height, 22pt);
306
324
  }
307
325
  :where(div.multiselect.open) {
308
326
  z-index: var(--sms-open-z-index, 4);
@@ -314,31 +332,33 @@ display above those of another following shortly after it -->
314
332
  background: var(--sms-readonly-bg, lightgray);
315
333
  }
316
334
 
317
- :where(ul.selected) {
335
+ :where(div.multiselect > ul.selected) {
318
336
  display: flex;
337
+ flex: 1;
319
338
  padding: 0;
320
339
  margin: 0;
321
340
  flex-wrap: wrap;
322
341
  }
323
- :where(ul.selected > li) {
324
- background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue));
342
+ :where(div.multiselect > ul.selected > li) {
325
343
  align-items: center;
326
344
  border-radius: 4pt;
327
345
  display: flex;
328
346
  margin: 2pt;
329
- padding: 0 0 0 1ex;
347
+ line-height: normal;
348
+ padding: 1pt 2pt 1pt 5pt;
330
349
  transition: 0.3s;
331
350
  white-space: nowrap;
332
- height: 16pt;
351
+ background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue));
352
+ height: var(--sms-selected-li-height);
333
353
  }
334
- :where(ul.selected > li button, button.remove-all) {
354
+ :where(div.multiselect > ul.selected > li button, button.remove-all) {
335
355
  align-items: center;
336
356
  border-radius: 50%;
337
357
  display: flex;
338
358
  cursor: pointer;
339
359
  transition: 0.2s;
340
360
  }
341
- :where(button) {
361
+ :where(div.multiselect button) {
342
362
  color: inherit;
343
363
  background: transparent;
344
364
  border: none;
@@ -349,46 +369,58 @@ display above those of another following shortly after it -->
349
369
  :where(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
350
370
  color: var(--sms-remove-x-hover-focus-color, lightskyblue);
351
371
  }
352
- :where(button:focus) {
372
+ :where(div.multiselect > button:focus) {
353
373
  transform: scale(1.04);
354
374
  }
355
375
 
356
- :where(div.multiselect > input) {
376
+ :where(div.multiselect > ul.selected > li > input) {
357
377
  border: none;
358
378
  outline: none;
359
379
  background: none;
360
- color: var(--sms-text-color, inherit);
361
380
  flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
362
381
  min-width: 2em;
363
382
  /* minimum font-size > 16px ensures iOS doesn't zoom in when focusing input */
364
383
  /* https://stackoverflow.com/a/6394497 */
365
384
  font-size: calc(16px + 0.1vw);
385
+ color: var(--sms-text-color, inherit);
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;
366
395
  }
367
396
 
368
- :where(ul.options) {
397
+ :where(div.multiselect > ul.options) {
369
398
  list-style: none;
370
- max-height: 50vh;
371
399
  padding: 0;
372
400
  top: 100%;
401
+ left: 0;
373
402
  width: 100%;
374
403
  position: absolute;
375
404
  border-radius: 1ex;
376
405
  overflow: auto;
377
406
  background: var(--sms-options-bg, white);
407
+ max-height: var(--sms-options-max-height, 50vh);
378
408
  overscroll-behavior: var(--sms-options-overscroll, none);
409
+ box-shadow: var(--sms-options-shadow, 0 0 14pt -8pt black);
379
410
  }
380
- :where(ul.options.hidden) {
411
+ :where(div.multiselect > ul.options.hidden) {
381
412
  visibility: hidden;
382
413
  }
383
- :where(ul.options li) {
414
+ :where(div.multiselect > ul.options > li) {
384
415
  padding: 3pt 2ex;
385
416
  cursor: pointer;
417
+ scroll-margin: var(--sms-options-scroll-margin, 100px);
386
418
  }
387
419
  /* for noOptionsMsg */
388
- :where(ul.options span) {
420
+ :where(div.multiselect > ul.options span) {
389
421
  padding: 3pt 2ex;
390
422
  }
391
- :where(ul.options li.selected) {
423
+ :where(div.multiselect > ul.options > li.selected) {
392
424
  border-left: var(
393
425
  --sms-li-selected-border-left,
394
426
  3pt solid var(--sms-selected-color, green)
@@ -396,22 +428,21 @@ display above those of another following shortly after it -->
396
428
  background: var(--sms-li-selected-bg, inherit);
397
429
  color: var(--sms-li-selected-color, inherit);
398
430
  }
399
- :where(ul.options li:not(.selected):hover) {
431
+ :where(div.multiselect > ul.options > li:not(.selected):hover) {
400
432
  border-left: var(
401
433
  --sms-li-not-selected-hover-border-left,
402
434
  3pt solid var(--sms-active-color, cornflowerblue)
403
435
  );
404
- border-left: 3pt solid var(--blue);
405
436
  }
406
- :where(ul.options li.active) {
437
+ :where(div.multiselect > ul.options > li.active) {
407
438
  background: var(--sms-li-active-bg, var(--sms-active-color, cornflowerblue));
408
439
  }
409
- :where(ul.options li.disabled) {
440
+ :where(div.multiselect > ul.options > li.disabled) {
441
+ cursor: not-allowed;
410
442
  background: var(--sms-li-disabled-bg, #f5f5f6);
411
443
  color: var(--sms-li-disabled-text, #b8b8b8);
412
- cursor: not-allowed;
413
444
  }
414
- :where(ul.options li.disabled:hover) {
445
+ :where(div.multiselect > ul.options > li.disabled:hover) {
415
446
  border-left: unset;
416
447
  }
417
448
  </style>
@@ -5,8 +5,10 @@ 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;
11
+ maxSelectMsg?: ((current: number, max: number) => string) | null | undefined;
10
12
  readonly?: boolean | undefined;
11
13
  options: ProtoOption[];
12
14
  input?: HTMLInputElement | null | undefined;
@@ -15,14 +17,20 @@ declare const __propDef: {
15
17
  name?: string | undefined;
16
18
  noOptionsMsg?: string | undefined;
17
19
  activeOption?: Option | null | undefined;
20
+ filterFunc?: ((op: Option, searchText: string) => boolean) | undefined;
18
21
  outerDivClass?: string | undefined;
19
22
  ulSelectedClass?: string | undefined;
20
23
  liSelectedClass?: string | undefined;
21
24
  ulOptionsClass?: string | undefined;
22
25
  liOptionClass?: string | undefined;
26
+ liActiveOptionClass?: string | undefined;
23
27
  removeBtnTitle?: string | undefined;
24
28
  removeAllTitle?: string | undefined;
25
29
  defaultDisabledTitle?: string | undefined;
30
+ allowUserOptions?: boolean | "append" | undefined;
31
+ autoScroll?: boolean | undefined;
32
+ loading?: boolean | undefined;
33
+ required?: boolean | undefined;
26
34
  };
27
35
  events: {
28
36
  mouseup: MouseEvent;
@@ -30,11 +38,12 @@ declare const __propDef: {
30
38
  [evt: string]: CustomEvent<any>;
31
39
  };
32
40
  slots: {
33
- renderSelected: {
41
+ selected: {
34
42
  option: Option;
35
43
  idx: any;
36
44
  };
37
- renderOptions: {
45
+ spinner: {};
46
+ option: {
38
47
  option: Option;
39
48
  idx: any;
40
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,16 +5,16 @@
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.1",
8
+ "version": "4.0.0",
9
9
  "type": "module",
10
10
  "svelte": "index.js",
11
11
  "bugs": "https://github.com/janosh/svelte-multiselect/issues",
12
12
  "devDependencies": {
13
- "@sveltejs/adapter-static": "^1.0.0-next.26",
14
- "@sveltejs/kit": "^1.0.0-next.259",
15
- "@typescript-eslint/eslint-plugin": "^5.10.2",
16
- "@typescript-eslint/parser": "^5.10.2",
17
- "eslint": "^8.8.0",
13
+ "@sveltejs/adapter-static": "^1.0.0-next.28",
14
+ "@sveltejs/kit": "^1.0.0-next.278",
15
+ "@typescript-eslint/eslint-plugin": "^5.12.0",
16
+ "@typescript-eslint/parser": "^5.12.0",
17
+ "eslint": "^8.9.0",
18
18
  "eslint-plugin-svelte3": "^3.4.0",
19
19
  "hastscript": "^7.0.2",
20
20
  "mdsvex": "^0.10.5",
@@ -22,15 +22,15 @@
22
22
  "prettier-plugin-svelte": "^2.6.0",
23
23
  "rehype-autolink-headings": "^6.1.1",
24
24
  "rehype-slug": "^5.0.1",
25
- "svelte": "^3.46.3",
26
- "svelte-check": "^2.4.2",
25
+ "svelte": "^3.46.4",
26
+ "svelte-check": "^2.4.5",
27
27
  "svelte-github-corner": "^0.1.0",
28
- "svelte-preprocess": "^4.10.2",
29
- "svelte-toc": "^0.2.3",
30
- "svelte2tsx": "^0.5.2",
28
+ "svelte-preprocess": "^4.10.3",
29
+ "svelte-toc": "^0.2.6",
30
+ "svelte2tsx": "^0.5.5",
31
31
  "tslib": "^2.3.1",
32
32
  "typescript": "^4.5.5",
33
- "vite": "^2.7.13"
33
+ "vite": "^2.8.4"
34
34
  },
35
35
  "keywords": [
36
36
  "svelte",
package/readme.md CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  <slot />
24
24
 
25
- ## Key Features
25
+ ## Key features
26
26
 
27
27
  - **Single / multiple select:** pass `maxSelect={1}` prop to only allow one selection
28
28
  - **Dropdowns:** scrollable lists for large numbers of options
@@ -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,43 +91,69 @@ 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
- | `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
- | `maxSelectMsg` | ``(current: number, max: number) => `${current}/${max}` `` | Function that returns a string informing the user how many of the maximum allowed options they have currently selected. Return empty string to disable, i.e. `() => ''`. |
97
- | `selected` | `[]` | Array of currently/pre-selected options when binding/passing as props respectively. |
98
- | `selectedLabels` | `[]` | Labels of currently selected options. |
99
- | `selectedValues` | `[]` | Values of currently selected options. |
100
- | `readonly` | `false` | Disable the component. It will still be rendered but users won't be able to interact with it. |
101
- | `placeholder` | `undefined` | String shown in the text input when no option is selected. |
102
- | `input` | `undefined` | Handle to the `<input>` DOM node. |
103
- | `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. |
104
- | `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. |
105
114
 
106
115
  </div>
107
116
 
117
+ ## Exposed methods
118
+
119
+ 1. `filterFunc = (op: Option, searchText: string) => boolean`: Determine what options are shown when user enters search string to filter dropdown list. Defaults to:
120
+
121
+ ```ts
122
+ filterFunc = (op: Option, searchText: string) => {
123
+ if (!searchText) return true
124
+ return `${op.label}`.toLowerCase().includes(searchText.toLowerCase())
125
+ }
126
+ ```
127
+
128
+ 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:
129
+
130
+ ```ts
131
+ maxSelectMsg = (current: number, max: number) => `${current}/${max}`
132
+ ```
133
+
108
134
  ## Slots
109
135
 
110
- `MultiSelect.svelte` accepts two named slots
136
+ `MultiSelect.svelte` has 3 named slots:
111
137
 
112
- - `slot="renderOptions"`
113
- - `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.
114
141
 
115
- 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:
116
143
 
117
144
  ```svelte
118
145
  <MultiSelect options={[`Banana`, `Apple`, `Mango`]}>
119
- <span let:idx let:option slot="renderOptions">
146
+ <span let:idx let:option slot="option">
120
147
  {idx + 1}. {option.label}
121
148
  {option.label === `Mango` ? `🎉` : ``}
122
149
  </span>
123
150
 
124
- <span let:idx let:option slot="renderSelected">
151
+ <span let:idx let:option slot="selected">
125
152
  #{idx + 1}
126
153
  {option.label}
127
154
  </span>
155
+
156
+ <CustomSpinner slot="spinner">
128
157
  </MultiSelect>
129
158
  ```
130
159
 
@@ -194,30 +223,40 @@ There are 3 ways to style this component. To understand which options do what, i
194
223
 
195
224
  If you only want to make small adjustments, you can pass the following CSS variables directly to the component as props or define them in a `:global()` CSS context.
196
225
 
197
- - `div.multiselect`:
226
+ - `div.multiselect`
198
227
  - `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.
199
- - `border-radius: var(--sms-border-radius, 5pt)`: Input border radius.
200
- - `background: var(--sms-input-bg)`: Input background.
201
- - `height: var(--sms-input-height, 2em)`: Input height.
202
- - `border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue))`: Border when focused. Falls back to `--sms-active-color` if not set which in turn falls back on `cornflowerblue`.
228
+ - `border-radius: var(--sms-border-radius, 5pt)`
229
+ - `background: var(--sms-input-bg)`
230
+ - `height: var(--sms-input-height, 2em)`
231
+ - `div.multiselect.open`
232
+ - `z-index: var(--sms-open-z-index, 4)`: Increase this if needed to ensure the dropdown list is displayed atop all other page elements.
233
+ - `div.multiselect:focus-within`
234
+ - `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`.
235
+ - `div.multiselect.readonly`
203
236
  - `background: var(--sms-readonly-bg, lightgray)`: Background when in readonly state.
204
- - `div.multiselect.open`:
205
- - `z-index: var(--sms-open-z-index, 4)`: Useful to ensure the dropdown list of options is displayed on top of other page elements of increased `z-index`.
206
- - `div.multiselect > input`
237
+ - `div.multiselect > ul.selected > li > input`
207
238
  - `color: var(--sms-text-color, inherit)`: Input text color.
208
- - `ul.selected > li`:
239
+ - `div.multiselect > ul.selected > li`
209
240
  - `background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue))`: Background of selected options.
210
- - `ul.selected > li button:hover, button.remove-all:hover`
241
+ - `height: var(--sms-selected-li-height)`: Height of selected options.
242
+ - `ul.selected > li button:hover, button.remove-all:hover, button:focus`
211
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.
212
- - `ul.options`
213
- - `background: var(--sms-options-bg, white)`: Background of options list.
214
- - `background: 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).
215
- - `ul.options > li.selected`
244
+ - `div.multiselect > ul.options`
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.
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).
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.
251
+ - `div.multiselect > ul.options > li.selected`
252
+ - `border-left: var(--sms-li-selected-border-left, 3pt solid var(--sms-selected-color, green))`
216
253
  - `background: var(--sms-li-selected-bg, inherit)`: Background of selected list items in options pane.
217
254
  - `color: var(--sms-li-selected-color, inherit)`: Text color of selected list items in options pane.
218
- - `ul.options > li.active`
255
+ - `div.multiselect > ul.options > li:not(.selected):hover`
256
+ - `border-left: var(--sms-li-not-selected-hover-border-left, 3pt solid var(--sms-active-color, cornflowerblue))`
257
+ - `div.multiselect > ul.options > li.active`
219
258
  - `background: var(--sms-li-active-bg, var(--sms-active-color, cornflowerblue))`: Background of active (currently with arrow keys highlighted) list item.
220
- - `ul.options > li.disabled`
259
+ - `div.multiselect > ul.options > li.disabled`
221
260
  - `background: var(--sms-li-disabled-bg, #f5f5f6)`: Background of disabled options in the dropdown list.
222
261
  - `color: var(--sms-li-disabled-text, #b8b8b8)`: Text color of disabled option in the dropdown list.
223
262
 
@@ -236,6 +275,7 @@ The second method allows you to pass in custom classes to the important DOM elem
236
275
  - `liSelectedClass`
237
276
  - `ulOptionsClass`
238
277
  - `liOptionClass`
278
+ - `liActiveOptionClass`
239
279
 
240
280
  This simplified version of the DOM structure of this component shows where these classes are inserted:
241
281
 
@@ -247,7 +287,9 @@ This simplified version of the DOM structure of this component shows where these
247
287
  </ul>
248
288
  <ul class="options {ulOptionsClass}">
249
289
  <li class={liOptionClass}>Option 1</li>
250
- <li class={liOptionClass}>Option 2</li>
290
+ <li class="{liOptionClass} {liActiveOptionClass}">
291
+ Option 2 (currently active)
292
+ </li>
251
293
  </ul>
252
294
  </div>
253
295
  ```
@@ -257,40 +299,47 @@ This simplified version of the DOM structure of this component shows where these
257
299
  You can alternatively style every part of this component with more fine-grained control by using the following `:global()` CSS selectors. `ul.selected` is the list of currently selected options rendered inside the component's input whereas `ul.options` is the list of available options that slides out when the component has focus.
258
300
 
259
301
  ```css
260
- :global(.multiselect) {
302
+ :global(div.multiselect) {
261
303
  /* top-level wrapper div */
262
304
  }
263
- :global(.multiselect ul.selected > li) {
264
- /* selected options */
305
+ :global(div.multiselect.open) {
306
+ /* top-level wrapper div when dropdown open */
265
307
  }
266
- :global(.multiselect ul.selected > li button),
267
- :global(.multiselect button.remove-all) {
308
+ :global(div.multiselect.readonly) {
309
+ /* top-level wrapper div when in readonly state */
310
+ }
311
+ :global(div.multiselect > ul.selected) {
312
+ /* selected list */
313
+ }
314
+ :global(div.multiselect > ul.selected > li) {
315
+ /* selected list items */
316
+ }
317
+ :global(div.multiselect button) {
318
+ /* target all buttons in this component */
319
+ }
320
+ :global(div.multiselect > ul.selected > li button, button.remove-all) {
268
321
  /* buttons to remove a single or all selected options at once */
269
322
  }
270
- :global(.multiselect ul.options) {
323
+ :global(div.multiselect > ul.selected > li > input) {
324
+ /* input inside the top-level wrapper div */
325
+ }
326
+ :global(div.multiselect > ul.options) {
271
327
  /* dropdown options */
272
328
  }
273
- :global(.multiselect ul.options li) {
274
- /* dropdown list of available options */
329
+ :global(div.multiselect > ul.options > li) {
330
+ /* dropdown list items */
275
331
  }
276
- :global(.multiselect ul.options li.selected) {
332
+ :global(div.multiselect > ul.options > li.selected) {
277
333
  /* selected options in the dropdown list */
278
334
  }
279
- :global(.multiselect ul.options li:not(.selected):hover) {
335
+ :global(div.multiselect > ul.options > li:not(.selected):hover) {
280
336
  /* unselected but hovered options in the dropdown list */
281
337
  }
282
- :global(.multiselect ul.options li.selected:hover) {
283
- /* selected and hovered options in the dropdown list */
284
- /* probably not necessary to style this state in most cases */
285
- }
286
- :global(.multiselect ul.options li.active) {
338
+ :global(div.multiselect > ul.options > li.active) {
287
339
  /* active means item was navigated to with up/down arrow keys */
288
340
  /* ready to be selected by pressing enter */
289
341
  }
290
- :global(.multiselect ul.options li.selected.active) {
291
- /* both active and already selected, pressing enter now will deselect the item */
292
- }
293
- :global(.multiselect ul.options li.disabled) {
342
+ :global(div.multiselect > ul.options > li.disabled) {
294
343
  /* options with disabled key set to true (see props above) */
295
344
  }
296
345
  ```