svelte-multiselect 1.2.3 → 3.1.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.
@@ -1,67 +1,113 @@
1
- <script >import { createEventDispatcher } from 'svelte';
1
+ <script >import { createEventDispatcher, onMount } from 'svelte';
2
2
  import { fly } from 'svelte/transition';
3
- import CrossIcon from './icons/Cross.svelte';
4
- import ExpandIcon from './icons/ChevronExpand.svelte';
5
- import ReadOnlyIcon from './icons/ReadOnly.svelte';
6
- export let selected;
3
+ import { onClickOutside } from './actions';
4
+ import { CrossIcon, ExpandIcon, ReadOnlyIcon } from './icons';
5
+ export let selected = [];
6
+ export let selectedLabels = [];
7
+ export let selectedValues = [];
7
8
  export let maxSelect = null; // null means any number of options are selectable
9
+ export let maxSelectMsg = (current, max) => `${current}/${max}`;
8
10
  export let readonly = false;
9
- export let placeholder = ``;
10
11
  export let options;
11
- export let disabledOptions = [];
12
12
  export let input = null;
13
- export let name = ``;
13
+ export let placeholder = undefined;
14
+ export let name = undefined;
15
+ export let id = undefined;
14
16
  export let noOptionsMsg = `No matching options`;
17
+ export let activeOption = null;
15
18
  export let outerDivClass = ``;
16
- export let ulTokensClass = ``;
17
- export let liTokenClass = ``;
19
+ export let ulSelectedClass = ``;
20
+ export let liSelectedClass = ``;
18
21
  export let ulOptionsClass = ``;
19
22
  export let liOptionClass = ``;
20
23
  export let removeBtnTitle = `Remove`;
21
24
  export let removeAllTitle = `Remove all`;
25
+ // https://github.com/sveltejs/svelte/issues/6964
26
+ export let defaultDisabledTitle = `This option is disabled`;
22
27
  if (maxSelect !== null && maxSelect < 0) {
23
- throw new TypeError(`maxSelect must be null or positive integer, got ${maxSelect}`);
28
+ console.error(`maxSelect must be null or positive integer, got ${maxSelect}`);
24
29
  }
25
- $: single = maxSelect === 1;
26
- if (!selected)
27
- selected = single ? `` : [];
28
30
  if (!(options?.length > 0))
29
31
  console.error(`MultiSelect missing options`);
30
- $: invalidDisabledOptions = disabledOptions.filter((opt) => !options.includes(opt));
31
- $: if (invalidDisabledOptions.length > 0) {
32
- console.error(`Some disabledOptions are invalid as they do not appear in the options list: ${invalidDisabledOptions}`);
32
+ if (!Array.isArray(selected))
33
+ console.error(`selected prop must be an array`);
34
+ onMount(() => {
35
+ selected = _options.filter((op) => op?.preselected);
36
+ });
37
+ function isObject(item) {
38
+ return typeof item === `object` && !Array.isArray(item) && item !== null;
33
39
  }
40
+ // process proto options to full ones with mandatory labels
41
+ $: _options = options.map((rawOp) => {
42
+ // convert to objects internally if user passed list of strings or numbers as options
43
+ if (isObject(rawOp)) {
44
+ const op = { ...rawOp };
45
+ if (!op.value)
46
+ op.value = op.label;
47
+ return op;
48
+ }
49
+ else {
50
+ if (![`string`, `number`].includes(typeof rawOp)) {
51
+ console.error(`MultiSelect options must be objects, strings or numbers, got ${typeof rawOp}`);
52
+ }
53
+ // even if we logged error above, try to proceed hoping user knows what they're doing
54
+ return { label: rawOp, value: rawOp };
55
+ }
56
+ });
57
+ $: labels = _options.map((op) => op.label);
58
+ $: if (new Set(labels).size !== options.length) {
59
+ console.error(`Option labels must be unique. Duplicates found: ${labels.filter((label, idx) => labels.indexOf(label) !== idx)}`);
60
+ }
61
+ $: selectedLabels = selected.map((op) => op.label);
62
+ $: selectedValues = selected.map((op) => op.value);
34
63
  const dispatch = createEventDispatcher();
35
- let activeOption, searchText;
64
+ let searchText = ``;
36
65
  let showOptions = false;
37
- $: filteredOptions = searchText
38
- ? options.filter((option) => option.toLowerCase().includes(searchText.toLowerCase()))
39
- : options;
40
- $: if ((activeOption && !filteredOptions.includes(activeOption)) ||
66
+ // options matching the current search text
67
+ $: matchingOptions = _options.filter((op) => {
68
+ if (!searchText)
69
+ return true;
70
+ return `${op.label}`.toLowerCase().includes(searchText.toLowerCase());
71
+ });
72
+ $: matchingEnabledOptions = matchingOptions.filter((op) => !op.disabled);
73
+ $: if (
74
+ // if there was an active option but it's not in the filtered list of options
75
+ (activeOption &&
76
+ !matchingEnabledOptions.map((op) => op.label).includes(activeOption.label)) ||
77
+ // or there's no active option but the user entered search text
41
78
  (!activeOption && searchText))
42
- activeOption = filteredOptions[0];
43
- function add(token) {
79
+ // make the first filtered option active
80
+ activeOption = matchingEnabledOptions[0];
81
+ function add(label) {
44
82
  if (!readonly &&
45
- !selected.includes(token) &&
46
- // (... || single) because in single mode, we always replace current token with new selection
47
- (maxSelect === null || selected.length < maxSelect || single)) {
83
+ !selectedLabels.includes(label) &&
84
+ // for maxselect = 1 we always replace current option with new selection
85
+ (maxSelect == null || maxSelect == 1 || selected.length < maxSelect)) {
48
86
  searchText = ``; // reset search string on selection
49
- selected = single ? token : [token, ...selected];
50
- if ((Array.isArray(selected) && selected.length === maxSelect) ||
51
- typeof selected === `string`) {
52
- setOptionsVisible(false);
53
- input?.blur();
87
+ const option = _options.find((op) => op.label === label);
88
+ if (!option) {
89
+ console.error(`MultiSelect: option with label ${label} not found`);
90
+ return;
54
91
  }
55
- dispatch(`add`, { token });
56
- dispatch(`change`, { token, type: `add` });
92
+ if (maxSelect === 1) {
93
+ selected = [option];
94
+ }
95
+ else {
96
+ selected = [option, ...selected];
97
+ }
98
+ if (selected.length === maxSelect)
99
+ setOptionsVisible(false);
100
+ dispatch(`add`, { option });
101
+ dispatch(`change`, { option, type: `add` });
57
102
  }
58
103
  }
59
- function remove(token) {
60
- if (readonly || typeof selected === `string`)
104
+ function remove(label) {
105
+ if (selected.length === 0 || readonly)
61
106
  return;
62
- selected = selected.filter((item) => item !== token);
63
- dispatch(`remove`, { token });
64
- dispatch(`change`, { token, type: `remove` });
107
+ selected = selected.filter((option) => label !== option.label);
108
+ const option = _options.find((option) => option.label === label);
109
+ dispatch(`remove`, { option });
110
+ dispatch(`change`, { option, type: `remove` });
65
111
  }
66
112
  function setOptionsVisible(show) {
67
113
  // nothing to do if visibility is already as intended
@@ -70,57 +116,75 @@ function setOptionsVisible(show) {
70
116
  showOptions = show;
71
117
  if (show)
72
118
  input?.focus();
119
+ else {
120
+ input?.blur();
121
+ activeOption = null;
122
+ }
73
123
  }
124
+ // handle all keyboard events this component receives
74
125
  function handleKeydown(event) {
126
+ // on escape: dismiss options dropdown and reset search text
75
127
  if (event.key === `Escape`) {
76
128
  setOptionsVisible(false);
77
129
  searchText = ``;
78
130
  }
131
+ // on enter key: toggle active option and reset search text
79
132
  else if (event.key === `Enter`) {
80
133
  if (activeOption) {
81
- if (isDisabled(activeOption))
82
- return;
83
- selected.includes(activeOption) ? remove(activeOption) : add(activeOption);
134
+ const { label } = activeOption;
135
+ selectedLabels.includes(label) ? remove(label) : add(label);
84
136
  searchText = ``;
85
- } // no active option means the options are closed in which case enter means open
137
+ } // no active option means the options dropdown is closed in which case enter means open it
86
138
  else
87
139
  setOptionsVisible(true);
88
140
  }
141
+ // on up/down arrow keys: update active option
89
142
  else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
143
+ if (activeOption === null) {
144
+ // if no option is active yet, make first one active
145
+ activeOption = matchingEnabledOptions[0];
146
+ return;
147
+ }
90
148
  const increment = event.key === `ArrowUp` ? -1 : 1;
91
- const newActiveIdx = filteredOptions.indexOf(activeOption) + increment;
149
+ const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
150
+ const ulOps = document.querySelector(`ul.options`);
92
151
  if (newActiveIdx < 0) {
93
- activeOption = filteredOptions[filteredOptions.length - 1];
152
+ // wrap around top
153
+ activeOption = matchingEnabledOptions[matchingEnabledOptions.length - 1];
154
+ if (ulOps)
155
+ ulOps.scrollTop = ulOps.scrollHeight;
156
+ }
157
+ else if (newActiveIdx === matchingEnabledOptions.length) {
158
+ // wrap around bottom
159
+ activeOption = matchingEnabledOptions[0];
160
+ if (ulOps)
161
+ ulOps.scrollTop = 0;
94
162
  }
95
163
  else {
96
- if (newActiveIdx === filteredOptions.length)
97
- activeOption = filteredOptions[0];
164
+ // default case
165
+ activeOption = matchingEnabledOptions[newActiveIdx];
166
+ const li = document.querySelector(`ul.options > li.active`);
167
+ // scrollIntoViewIfNeeded() scrolls top edge of element into view so when moving
168
+ // downwards, we scroll to next sibling to make element fully visible
169
+ if (increment === 1)
170
+ li?.nextSibling?.scrollIntoViewIfNeeded();
98
171
  else
99
- activeOption = filteredOptions[newActiveIdx];
172
+ li?.scrollIntoViewIfNeeded();
100
173
  }
101
174
  }
102
175
  else if (event.key === `Backspace`) {
103
- // only remove selected tags on backspace if if there are any and no searchText characters remain
104
- if (selected.length > 0 && searchText.length === 0) {
105
- selected = selected.slice(0, selected.length - 1);
106
- }
176
+ const label = selectedLabels.pop();
177
+ if (label && !searchText)
178
+ remove(label);
107
179
  }
108
180
  }
109
181
  const removeAll = () => {
110
- dispatch(`remove`, { token: selected });
111
- dispatch(`change`, { token: selected, type: `remove` });
112
- selected = single ? `` : [];
182
+ dispatch(`removeAll`, { options: selected });
183
+ dispatch(`change`, { options: selected, type: `removeAll` });
184
+ selected = [];
113
185
  searchText = ``;
114
186
  };
115
- const isDisabled = (option) => disabledOptions.includes(option);
116
- $: isSelected = (option) => {
117
- if (!(selected?.length > 0))
118
- return false; // nothing is selected if `selected` is the empty array or string
119
- if (single)
120
- return selected === option;
121
- else
122
- return selected.includes(option);
123
- };
187
+ $: isSelected = (label) => selectedLabels.includes(label);
124
188
  const handleEnterAndSpaceKeys = (handler) => (event) => {
125
189
  if ([`Enter`, `Space`].includes(event.code)) {
126
190
  event.preventDefault();
@@ -129,37 +193,37 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
129
193
  };
130
194
  </script>
131
195
 
132
- <!-- z-index: 2 when showOptions is true ensures the ul.tokens of one <MultiSelect /> display above those of another following shortly after it -->
196
+ <!-- z-index: 2 when showOptions is true ensures the ul.selected of one <MultiSelect />
197
+ display above those of another following shortly after it -->
133
198
  <div
199
+ {id}
134
200
  class="multiselect {outerDivClass}"
135
201
  class:readonly
136
- class:single
137
- style={showOptions ? `z-index: 2;` : ``}
138
- on:mouseup|stopPropagation={() => setOptionsVisible(true)}>
139
- <ExpandIcon height="14pt" style="padding-left: 1pt;" />
140
- <ul class="tokens {ulTokensClass}">
141
- {#if single}
142
- <span on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}>
143
- {selected}
144
- </span>
145
- {:else if selected?.length > 0}
146
- {#each selected as tag}
147
- <li
148
- class={liTokenClass}
149
- on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}>
150
- {tag}
151
- {#if !readonly}
152
- <button
153
- on:mouseup|stopPropagation={() => remove(tag)}
154
- on:keydown={handleEnterAndSpaceKeys(() => remove(tag))}
155
- type="button"
156
- title="{removeBtnTitle} {tag}">
157
- <CrossIcon height="12pt" />
158
- </button>
159
- {/if}
160
- </li>
161
- {/each}
162
- {/if}
202
+ class:single={maxSelect == 1}
203
+ style={showOptions ? `z-index: 2;` : undefined}
204
+ on:mouseup|stopPropagation={() => setOptionsVisible(true)}
205
+ use:onClickOutside={() => setOptionsVisible(false)}
206
+ use:onClickOutside={() => dispatch(`blur`)}
207
+ >
208
+ <ExpandIcon height="14pt" style="padding: 0 3pt 0 1pt;" />
209
+ <ul class="selected {ulSelectedClass}">
210
+ {#each selected as option, idx}
211
+ <li class={liSelectedClass}>
212
+ <slot name="selectedRenderer" {option} {idx}>
213
+ {option.label}
214
+ </slot>
215
+ {#if !readonly}
216
+ <button
217
+ on:mouseup|stopPropagation={() => remove(option.label)}
218
+ on:keydown={handleEnterAndSpaceKeys(() => remove(option.label))}
219
+ type="button"
220
+ title="{removeBtnTitle} {option.label}"
221
+ >
222
+ <CrossIcon height="12pt" />
223
+ </button>
224
+ {/if}
225
+ </li>
226
+ {/each}
163
227
  <input
164
228
  bind:this={input}
165
229
  autocomplete="off"
@@ -167,21 +231,23 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
167
231
  on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
168
232
  on:keydown={handleKeydown}
169
233
  on:focus={() => setOptionsVisible(true)}
170
- on:blur={() => dispatch(`blur`)}
171
- on:blur={() => setOptionsVisible(false)}
172
234
  {name}
173
- placeholder={selected.length ? `` : placeholder} />
235
+ placeholder={selectedLabels.length ? `` : placeholder}
236
+ />
174
237
  </ul>
175
238
  {#if readonly}
176
239
  <ReadOnlyIcon height="14pt" />
177
- {:else}
240
+ {:else if selected.length > 0}
241
+ {#if maxSelect !== null && maxSelect > 1}
242
+ <span style="padding: 0 3pt;">{maxSelectMsg(selected.length, maxSelect)}</span>
243
+ {/if}
178
244
  <button
179
245
  type="button"
180
246
  class="remove-all"
181
247
  title={removeAllTitle}
182
248
  on:mouseup|stopPropagation={removeAll}
183
249
  on:keydown={handleEnterAndSpaceKeys(removeAll)}
184
- style={selected.length === 0 ? `display: none;` : ``}>
250
+ >
185
251
  <CrossIcon height="14pt" />
186
252
  </button>
187
253
  {/if}
@@ -190,20 +256,26 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
190
256
  <ul
191
257
  class="options {ulOptionsClass}"
192
258
  class:hidden={!showOptions}
193
- transition:fly={{ duration: 300, y: 40 }}>
194
- {#each filteredOptions as option}
259
+ transition:fly|local={{ duration: 300, y: 40 }}
260
+ >
261
+ {#each matchingOptions as option, idx}
262
+ {@const { label, disabled, title = null, selectedTitle } = option}
263
+ {@const { disabledTitle = defaultDisabledTitle } = option}
195
264
  <li
196
265
  on:mouseup|preventDefault|stopPropagation
197
266
  on:mousedown|preventDefault|stopPropagation={() => {
198
- if (isDisabled(option)) return
199
-
200
- isSelected(option) ? remove(option) : add(option)
267
+ if (disabled) return
268
+ isSelected(label) ? remove(label) : add(label)
201
269
  }}
202
- class:selected={isSelected(option)}
203
- class:active={activeOption === option}
204
- class:disabled={isDisabled(option)}
205
- class={liOptionClass}>
206
- {option}
270
+ class:selected={isSelected(label)}
271
+ class:active={activeOption?.label === label}
272
+ class:disabled
273
+ title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
274
+ class={liOptionClass}
275
+ >
276
+ <slot name="optionRenderer" {option} {idx}>
277
+ {option.label}
278
+ </slot>
207
279
  </li>
208
280
  {:else}
209
281
  {noOptionsMsg}
@@ -222,6 +294,7 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
222
294
  min-height: 18pt;
223
295
  display: flex;
224
296
  cursor: text;
297
+ padding: 0 3pt;
225
298
  }
226
299
  :where(.multiselect:focus-within) {
227
300
  border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
@@ -230,8 +303,8 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
230
303
  background: var(--sms-readonly-bg, lightgray);
231
304
  }
232
305
 
233
- :where(ul.tokens > li) {
234
- background: var(--sms-token-bg, var(--sms-active-color, cornflowerblue));
306
+ :where(ul.selected > li) {
307
+ background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue));
235
308
  align-items: center;
236
309
  border-radius: 4pt;
237
310
  display: flex;
@@ -241,7 +314,7 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
241
314
  white-space: nowrap;
242
315
  height: 16pt;
243
316
  }
244
- :where(ul.tokens > li button, button.remove-all) {
317
+ :where(ul.selected > li button, button.remove-all) {
245
318
  align-items: center;
246
319
  border-radius: 50%;
247
320
  display: flex;
@@ -256,7 +329,7 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
256
329
  outline: none;
257
330
  padding: 0 2pt;
258
331
  }
259
- :where(ul.tokens > li button:hover, button.remove-all:hover) {
332
+ :where(ul.selected > li button:hover, button.remove-all:hover) {
260
333
  color: var(--sms-remove-x-hover-focus-color, lightskyblue);
261
334
  }
262
335
  :where(button:focus) {
@@ -271,14 +344,18 @@ const handleEnterAndSpaceKeys = (handler) => (event) => {
271
344
  color: var(--sms-text-color, inherit);
272
345
  flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
273
346
  min-width: 2em;
347
+ /* minimum font-size > 16px ensures iOS doesn't zoom in when focusing input */
348
+ /* https://stackoverflow.com/a/6394497 */
349
+ font-size: calc(16px + 0.1vw);
274
350
  }
275
351
 
276
- :where(ul.tokens) {
352
+ :where(ul.selected) {
277
353
  display: flex;
278
354
  padding: 0;
279
355
  margin: 0;
280
356
  flex-wrap: wrap;
281
357
  flex: 1;
358
+ overscroll-behavior: none;
282
359
  }
283
360
 
284
361
  :where(ul.options) {
@@ -1,29 +1,44 @@
1
1
  import { SvelteComponentTyped } from "svelte";
2
+ import type { Option, Primitive, ProtoOption } from './';
2
3
  declare const __propDef: {
3
4
  props: {
4
- selected: string[] | string;
5
+ selected?: Option[] | undefined;
6
+ selectedLabels?: Primitive[] | undefined;
7
+ selectedValues?: Primitive[] | undefined;
5
8
  maxSelect?: number | null | undefined;
9
+ maxSelectMsg?: ((current: number, max: number) => string) | undefined;
6
10
  readonly?: boolean | undefined;
7
- placeholder?: string | undefined;
8
- options: string[];
9
- disabledOptions?: string[] | undefined;
11
+ options: ProtoOption[];
10
12
  input?: HTMLInputElement | null | undefined;
13
+ placeholder?: string | undefined;
11
14
  name?: string | undefined;
15
+ id?: string | undefined;
12
16
  noOptionsMsg?: string | undefined;
17
+ activeOption?: Option | null | undefined;
13
18
  outerDivClass?: string | undefined;
14
- ulTokensClass?: string | undefined;
15
- liTokenClass?: string | undefined;
19
+ ulSelectedClass?: string | undefined;
20
+ liSelectedClass?: string | undefined;
16
21
  ulOptionsClass?: string | undefined;
17
22
  liOptionClass?: string | undefined;
18
23
  removeBtnTitle?: string | undefined;
19
24
  removeAllTitle?: string | undefined;
25
+ defaultDisabledTitle?: string | undefined;
20
26
  };
21
27
  events: {
22
28
  mouseup: MouseEvent;
23
29
  } & {
24
30
  [evt: string]: CustomEvent<any>;
25
31
  };
26
- slots: {};
32
+ slots: {
33
+ selectedRenderer: {
34
+ option: Option;
35
+ idx: any;
36
+ };
37
+ optionRenderer: {
38
+ option: Option;
39
+ idx: any;
40
+ };
41
+ };
27
42
  };
28
43
  export declare type MultiSelectProps = typeof __propDef.props;
29
44
  export declare type MultiSelectEvents = typeof __propDef.events;
package/actions.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare function onClickOutside(node: HTMLElement, cb?: () => void): {
2
+ destroy(): void;
3
+ };
package/actions.js ADDED
@@ -0,0 +1,39 @@
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
+ }
17
+ // import { spring } from 'svelte/motion'
18
+ // export default function boop(node: HTMLElement, params = {}) {
19
+ // const { setter } = params
20
+ // const springyRotation = spring(
21
+ // { x: 0, y: 0, rotation: 0, scale: 1 },
22
+ // { stiffness: 0.1, damping: 0.15 }
23
+ // )
24
+ // node.style.display = `inline-block`
25
+ // const unsubscribe = springyRotation.subscribe(({ x, y, rotation, scale }) => {
26
+ // node.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg) scale(${scale})`
27
+ // })
28
+ // return {
29
+ // update({ isBooped: x = 0, y = 0, rotation = 0, scale = 1, timing }) {
30
+ // springyRotation.set(
31
+ // isBooped
32
+ // ? { x, y, rotation, scale }
33
+ // : { x: 0, y: 0, rotation: 0, scale: 1 }
34
+ // )
35
+ // if (isBooped) window.setTimeout(() => setter(false), timing)
36
+ // },
37
+ // destroy: unsubscribe,
38
+ // }
39
+ // }
@@ -5,5 +5,6 @@ export let style = ``;
5
5
 
6
6
  <svg {width} {height} {style} fill="currentColor" viewBox="0 0 16 16">
7
7
  <path
8
- d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z" />
8
+ d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"
9
+ />
9
10
  </svg>
@@ -5,5 +5,6 @@ export let style = ``;
5
5
 
6
6
  <svg {width} {height} {style} viewBox="0 0 20 20" fill="currentColor">
7
7
  <path
8
- d="M10 1.6a8.4 8.4 0 100 16.8 8.4 8.4 0 000-16.8zm4.789 11.461L13.06 14.79 10 11.729l-3.061 3.06L5.21 13.06 8.272 10 5.211 6.939 6.94 5.211 10 8.271l3.061-3.061 1.729 1.729L11.728 10l3.061 3.061z" />
8
+ d="M10 1.6a8.4 8.4 0 100 16.8 8.4 8.4 0 000-16.8zm4.789 11.461L13.06 14.79 10 11.729l-3.061 3.06L5.21 13.06 8.272 10 5.211 6.939 6.94 5.211 10 8.271l3.061-3.061 1.729 1.729L11.728 10l3.061 3.061z"
9
+ />
9
10
  </svg>
@@ -6,5 +6,6 @@ export let style = ``;
6
6
  <svg {width} {height} {style} viewBox="0 0 24 24" fill="currentColor">
7
7
  <path fill="none" d="M0 0h24v24H0V0z" />
8
8
  <path
9
- d="M14.48 11.95c.17.02.34.05.52.05 2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4c0 .18.03.35.05.52l3.43 3.43zm2.21 2.21L22.53 20H23v-2c0-2.14-3.56-3.5-6.31-3.84zM0 3.12l4 4V10H1v2h3v3h2v-3h2.88l2.51 2.51C9.19 15.11 7 16.3 7 18v2h9.88l4 4 1.41-1.41L1.41 1.71 0 3.12zM6.88 10H6v-.88l.88.88z" />
9
+ d="M14.48 11.95c.17.02.34.05.52.05 2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4c0 .18.03.35.05.52l3.43 3.43zm2.21 2.21L22.53 20H23v-2c0-2.14-3.56-3.5-6.31-3.84zM0 3.12l4 4V10H1v2h3v3h2v-3h2.88l2.51 2.51C9.19 15.11 7 16.3 7 18v2h9.88l4 4 1.41-1.41L1.41 1.71 0 3.12zM6.88 10H6v-.88l.88.88z"
10
+ />
10
11
  </svg>
@@ -0,0 +1,3 @@
1
+ export { default as CrossIcon } from './Cross.svelte';
2
+ export { default as ExpandIcon } from './ChevronExpand.svelte';
3
+ export { default as ReadOnlyIcon } from './ReadOnly.svelte';
package/icons/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default as CrossIcon } from './Cross.svelte';
2
+ export { default as ExpandIcon } from './ChevronExpand.svelte';
3
+ export { default as ReadOnlyIcon } from './ReadOnly.svelte';
package/index.d.ts CHANGED
@@ -1 +1,15 @@
1
1
  export { default } from './MultiSelect.svelte';
2
+ export declare type Primitive = string | number;
3
+ export declare type Option = {
4
+ label: Primitive;
5
+ value: Primitive;
6
+ title?: string;
7
+ disabled?: boolean;
8
+ preselected?: boolean;
9
+ disabledTitle?: string;
10
+ selectedTitle?: string;
11
+ [key: string]: unknown;
12
+ };
13
+ export declare type ProtoOption = Primitive | (Omit<Option, `value`> & {
14
+ value?: Primitive;
15
+ });
package/package.json CHANGED
@@ -5,33 +5,32 @@
5
5
  "homepage": "https://svelte-multiselect.netlify.app",
6
6
  "repository": "https://github.com/janosh/svelte-multiselect",
7
7
  "license": "MIT",
8
- "version": "1.2.3",
8
+ "version": "3.1.0",
9
9
  "type": "module",
10
- "svelte": "MultiSelect.svelte",
11
- "bugs": {
12
- "url": "https://github.com/janosh/svelte-multiselect/issues"
13
- },
10
+ "svelte": "index.js",
11
+ "bugs": "https://github.com/janosh/svelte-multiselect/issues",
14
12
  "devDependencies": {
15
- "@sveltejs/adapter-static": "^1.0.0-next.21",
16
- "@sveltejs/kit": "^1.0.0-next.187",
17
- "@typescript-eslint/eslint-plugin": "^5.1.0",
18
- "@typescript-eslint/parser": "^5.1.0",
19
- "eslint": "^8.0.1",
20
- "eslint-plugin-svelte3": "^3.2.1",
13
+ "@sveltejs/adapter-static": "^1.0.0-next.26",
14
+ "@sveltejs/kit": "^1.0.0-next.239",
15
+ "@typescript-eslint/eslint-plugin": "^5.10.0",
16
+ "@typescript-eslint/parser": "^5.10.0",
17
+ "eslint": "^8.7.0",
18
+ "eslint-plugin-svelte3": "^3.4.0",
21
19
  "hastscript": "^7.0.2",
22
20
  "mdsvex": "^0.9.8",
23
- "prettier": "^2.4.1",
24
- "prettier-plugin-svelte": "^2.4.0",
25
- "rehype-autolink-headings": "^6.1.0",
26
- "rehype-slug": "^5.0.0",
27
- "svelte": "^3.44.0",
28
- "svelte-check": "^2.2.7",
29
- "svelte-preprocess": "^4.9.8",
30
- "svelte-toc": "^0.1.9",
31
- "svelte2tsx": "^0.4.7",
21
+ "prettier": "^2.5.1",
22
+ "prettier-plugin-svelte": "^2.6.0",
23
+ "rehype-autolink-headings": "^6.1.1",
24
+ "rehype-slug": "^5.0.1",
25
+ "svelte": "^3.46.2",
26
+ "svelte-check": "^2.3.0",
27
+ "svelte-github-corner": "^0.1.0",
28
+ "svelte-preprocess": "^4.10.2",
29
+ "svelte-toc": "^0.2.2",
30
+ "svelte2tsx": "^0.4.14",
32
31
  "tslib": "^2.3.1",
33
- "typescript": "^4.4.4",
34
- "vite": "^2.6.10"
32
+ "typescript": "^4.5.5",
33
+ "vite": "^2.7.13"
35
34
  },
36
35
  "keywords": [
37
36
  "svelte",
package/readme.md CHANGED
@@ -1,10 +1,7 @@
1
- <div class="maybe-hide">
2
-
3
- <p align="center">
4
- <img src="static/favicon.svg" alt="Svelte MultiSelect" height=80>
5
- </p>
6
-
7
- <h1 align="center">Svelte MultiSelect</h1>
1
+ <h1 align="center">
2
+ <img src="https://raw.githubusercontent.com/janosh/svelte-toc/main/static/favicon.svg" alt="Svelte MultiSelect" height=60>
3
+ <br>&ensp;Svelte MultiSelect
4
+ </h1>
8
5
 
9
6
  <h4 align="center">
10
7
 
@@ -14,13 +11,15 @@
14
11
 
15
12
  </h4>
16
13
 
14
+ <div class="hide-in-docs">
15
+
17
16
  **[Live demo](https://svelte-multiselect.netlify.app)**.
18
17
 
19
18
  </div>
20
19
 
21
- <!-- remove above in docs -->
20
+ **Keyboard-friendly, zero-dependency multi-select Svelte component.**
22
21
 
23
- Keyboard-friendly, zero-dependency multi-select Svelte component.
22
+ <slot />
24
23
 
25
24
  ## Key Features
26
25
 
@@ -29,10 +28,22 @@ Keyboard-friendly, zero-dependency multi-select Svelte component.
29
28
  - **Searchable:** start typing to filter options
30
29
  - **Tagging:** selected options are recorded as tags within the text input
31
30
  - **Server-side rendering:** no reliance on browser objects like `window` or `document`
32
- - **Configurable:** see section [props](#props)
31
+ - **Configurable:** see [props](#props)
33
32
  - **No dependencies:** needs only Svelte as dev dependency
34
33
  - **Keyboard friendly** for mouse-less form completion
35
34
 
35
+ ## Recent breaking changes
36
+
37
+ - v2.0.0 added the ability to pass options as objects. As a result, `bind:selected` no longer returns simple strings but objects as well, even if you still pass in `options` as strings.
38
+ - v3.0.0 changed the `event.detail` payload for `'add'`, `'remove'` and `'change'` events from `token` to `option`, e.g.
39
+
40
+ ```js
41
+ on:add={(e) => console.log(e.detail.token.label)} // v2.0.0
42
+ on:add={(e) => console.log(e.detail.option.label)} // v3.0.0
43
+ ```
44
+
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`.
46
+
36
47
  ## Installation
37
48
 
38
49
  ```sh
@@ -75,52 +86,87 @@ Full list of props/bindable variables for this component:
75
86
 
76
87
  <div class="table">
77
88
 
78
- | name | default | description |
79
- | :---------------- | :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
- | `options` | N/A (required prop) | Array of strings that will be listed in the dropdown selection. |
81
- | `maxSelect` | `null` | `null` or positive integer to allow users to select as many as they like or a maximum number of options, respectively. |
82
- | `selected` | `[]` (or `''` if `maxSelect === 1`) | Array of currently/pre-selected options when binding/passing as props respectively. |
83
- | `readonly` | `false` | Disables the component. It will still be rendered but users won't be able to interact with it. |
84
- | `placeholder` | `''` | String shown in the text input when no option is selected. |
85
- | `disabledOptions` | `[]` | Array of strings to be disabled in the dropdown selection. Corresponding `<li>` elements in the dropdown list can be styled through the `ul.options li.disabled` selector. |
86
- | `input` | `undefined` | Handle to the `<input>` DOM node. |
87
- | `name` | `''` | Passed to the `<input>` for associating HTML form `<label>`s with this component. E.g. clicking a `<label>` with same name will focus this component. |
89
+ <!-- prettier-ignore -->
90
+ | name | default | description |
91
+ | :--------------- | :--------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
92
+ | `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. |
93
+ | `activeOption` | `null` | Currently active option, i.e. the one the user currently hovers or navigated to with arrow keys. |
94
+ | `maxSelect` | `null` | Positive integer to limit the number of options users can pick. `null` means no limit. |
95
+ | `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. `() => ''`. |
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
+ | `name` | `undefined` | Passed to the `<input>` for associating HTML form `<label>`s with this component. E.g. clicking a `<label>` with same name will focus this component. |
103
+ | `id` | `undefined` | Applied to the top-level `<div>` e.g. for `document.getElementById()`. |
88
104
 
89
105
  </div>
90
106
 
107
+ ## Slots
108
+
109
+ `MultiSelect.svelte` accepts two named slots
110
+
111
+ - `slot="optionRenderer"`
112
+ - `slot="selectedRenderer"`
113
+
114
+ 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:
115
+
116
+ ```svelte
117
+ <MultiSelect options={[`Banana`, `Watermelon`, `Apple`, `Dates`, `Mango`]}>
118
+ <span let:idx let:option slot="optionRenderer">
119
+ {idx + 1}. {option.label} {option.label === `Mango` ? `🎉` : ``}</span
120
+ >
121
+ <span let:idx let:option slot="selectedRenderer">
122
+ #️⃣ {idx + 1} {option.label}</span
123
+ >
124
+ </MultiSelect>
125
+ ```
126
+
91
127
  ## Events
92
128
 
93
129
  `MultiSelect.svelte` dispatches the following events:
94
130
 
95
- | name | details | description |
96
- | -------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
97
- | `add` | `token: string` | Triggers when a new token is selected. |
98
- | `remove` | `token: string` | Triggers when one or all selected tokens are removed. `event.detail.token` will be a single or multiple tokens, respectively. |
99
- | `change` | `token: string`, `type: string` | Triggers when a token is either added or removed, or all tokens are removed at once. `event.detail.type` will be either `'add'` or `'remove'`. |
100
- | `blur` | none | Triggers when the input field looses focus. |
131
+ | name | detail | description |
132
+ | ----------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
133
+ | `add` | `{ option: Option }` | Triggers when a new option is selected. |
134
+ | `remove` | `{ option: Option }` | Triggers when one selected option provided as `event.detail.option` is removed. |
135
+ | `removeAll` | `options: Option[]` | Triggers when all selected options are removed. The payload `event.detail.options` gives the options that were previously selected. |
136
+ | `change` | `{ option?: Option, options?: Option[] }`, `type: 'add' \| 'remove' \| 'removeAll'` | Triggers when a option is either added or removed, or all options are removed at once. |
137
+ | `blur` | none | Triggers when the input field looses focus. |
101
138
 
102
139
  ### Examples
103
140
 
104
141
  <!-- prettier-ignore -->
105
- - `on:add={(event) => console.log(event.detail.token)}`
106
- - `on:remove={(event) => console.log(event.detail.token)}`.
107
- - ``on:change={(event) => console.log(`${event.detail.type}: '${event.detail.token}'`)}``
142
+ - `on:add={(event) => console.log(event.detail.option.label)}`
143
+ - `on:remove={(event) => console.log(event.detail.option.label)}`.
144
+ - ``on:change={(event) => console.log(`${event.detail.type}: '${event.detail.option.label}'`)}``
108
145
  - `on:blur={yourFunctionHere}`
109
146
 
110
147
  ```svelte
111
148
  <MultiSelect
112
- on:change={(e) => alert(`You ${e.detail.type}ed '${e.detail.token}'`)} />
149
+ on:change={(e) => alert(`You ${e.detail.type}ed '${e.detail.option.label}'`)}
150
+ />
113
151
  ```
114
152
 
115
- ## Want to contribute?
153
+ ## TypeScript
116
154
 
117
- To submit a PR, clone the repo, install dependencies and start the dev server to try out your changes first.
155
+ TypeScript users can import the types used for internal type safety for external use as well:
118
156
 
119
- ```sh
120
- git clone https://github.com/janosh/svelte-multiselect
121
- cd svelte-multiselect
122
- yarn
123
- yarn dev
157
+ ```svelte
158
+ <script lang="ts">
159
+ import MultiSelect, {
160
+ Option,
161
+ Primitive,
162
+ ProtoOption,
163
+ } from 'svelte-multiselect'
164
+
165
+ const myOptions: Option[] = [
166
+ { label: 'foo', value: 42 },
167
+ { label: 'bar', value: 69 },
168
+ ]
169
+ </script>
124
170
  ```
125
171
 
126
172
  ## Styling
@@ -136,8 +182,8 @@ The first, if you only want to make small adjustments, allows you to pass the fo
136
182
  - `color: var(--sms-text-color, inherit)`: Input text color.
137
183
  - `border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue))`: `div.multiselect` border when focused. Falls back to `--sms-active-color` if not set which in turn falls back on `cornflowerblue`.
138
184
  - `background: var(--sms-readonly-bg, lightgray)`: Background when in readonly state.
139
- - `background: var(--sms-token-bg, var(--sms-active-color, cornflowerblue))`: Background of selected tokens.
140
- - `color: var(--sms-remove-x-hover+focus-color, lightgray)`: Hover color of cross icon to remove selected tokens.
185
+ - `background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue))`: Background of selected options.
186
+ - `color: var(--sms-remove-x-hover+focus-color, lightgray)`: Hover color of cross icon to remove selected options.
141
187
  - `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.
142
188
  - `background: var(--sms-options-bg, white)`: Background of options list.
143
189
  - `background: var(--sms-li-selected-bg, inherit)`: Background of selected list items in options pane.
@@ -157,8 +203,8 @@ For example, to change the background color of the options dropdown:
157
203
  The second method allows you to pass in custom classes to the important DOM elements of this component to target them with frameworks like [Tailwind CSS](https://tailwindcss.com).
158
204
 
159
205
  - `outerDivClass`
160
- - `ulTokensClass`
161
- - `liTokenClass`
206
+ - `ulSelectedClass`
207
+ - `liSelectedClass`
162
208
  - `ulOptionsClass`
163
209
  - `liOptionClass`
164
210
 
@@ -166,9 +212,9 @@ This simplified version of the DOM structure of this component shows where these
166
212
 
167
213
  ```svelte
168
214
  <div class={outerDivClass}>
169
- <ul class={ulTokensClass}>
170
- <li class={liTokenClass}>First selected tag</li>
171
- <li class={liTokenClass}>Second selected tag</li>
215
+ <ul class={ulSelectedClass}>
216
+ <li class={liSelectedClass}>First selected tag</li>
217
+ <li class={liSelectedClass}>Second selected tag</li>
172
218
  </ul>
173
219
  <ul class={ulOptionsClass}>
174
220
  <li class={liOptionClass}>First available option</li>
@@ -179,16 +225,16 @@ This simplified version of the DOM structure of this component shows where these
179
225
 
180
226
  ### Granular control through global CSS
181
227
 
182
- You can alternatively style every part of this component with more fine-grained control by using the following `:global()` CSS selectors.
228
+ 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.
183
229
 
184
230
  ```css
185
231
  :global(.multiselect) {
186
232
  /* top-level wrapper div */
187
233
  }
188
- :global(.multiselect ul.tokens > li) {
189
- /* the blue tags representing selected options with remove buttons inside the input */
234
+ :global(.multiselect ul.selected > li) {
235
+ /* selected options */
190
236
  }
191
- :global(.multiselect ul.tokens > li button),
237
+ :global(.multiselect ul.selected > li button),
192
238
  :global(.multiselect button.remove-all) {
193
239
  /* buttons to remove a single or all selected options at once */
194
240
  }
@@ -196,7 +242,7 @@ You can alternatively style every part of this component with more fine-grained
196
242
  /* dropdown options */
197
243
  }
198
244
  :global(.multiselect ul.options li) {
199
- /* dropdown options */
245
+ /* dropdown list of available options */
200
246
  }
201
247
  :global(.multiselect ul.options li.selected) {
202
248
  /* selected options in the dropdown list */
@@ -209,13 +255,24 @@ You can alternatively style every part of this component with more fine-grained
209
255
  /* probably not necessary to style this state in most cases */
210
256
  }
211
257
  :global(.multiselect ul.options li.active) {
212
- /* active means element was navigated to with up/down arrow keys */
258
+ /* active means item was navigated to with up/down arrow keys */
213
259
  /* ready to be selected by pressing enter */
214
260
  }
215
261
  :global(.multiselect ul.options li.selected.active) {
216
- /* both active and already selected, pressing enter now will deselect the element again */
262
+ /* both active and already selected, pressing enter now will deselect the item */
217
263
  }
218
264
  :global(.multiselect ul.options li.disabled) {
219
- /* this option is one included in disabledOptions (see props above) */
265
+ /* options with disabled key set to true (see props above) */
220
266
  }
221
267
  ```
268
+
269
+ ## Want to contribute?
270
+
271
+ To submit a PR, clone the repo, install dependencies and start the dev server to try out your changes.
272
+
273
+ ```sh
274
+ git clone https://github.com/janosh/svelte-multiselect
275
+ cd svelte-multiselect
276
+ yarn
277
+ yarn dev
278
+ ```