svelte-multiselect 1.2.1 → 3.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.
@@ -1,66 +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 placeholder = undefined;
14
+ export let name = undefined;
15
+ export let id = undefined;
13
16
  export let noOptionsMsg = `No matching options`;
17
+ export let activeOption = null;
14
18
  export let outerDivClass = ``;
15
- export let ulTokensClass = ``;
16
- export let liTokenClass = ``;
19
+ export let ulSelectedClass = ``;
20
+ export let liSelectedClass = ``;
17
21
  export let ulOptionsClass = ``;
18
22
  export let liOptionClass = ``;
19
23
  export let removeBtnTitle = `Remove`;
20
24
  export let removeAllTitle = `Remove all`;
25
+ // https://github.com/sveltejs/svelte/issues/6964
26
+ export let defaultDisabledTitle = `This option is disabled`;
21
27
  if (maxSelect !== null && maxSelect < 0) {
22
- throw new TypeError(`maxSelect must be null or positive integer, got ${maxSelect}`);
28
+ console.error(`maxSelect must be null or positive integer, got ${maxSelect}`);
23
29
  }
24
- $: single = maxSelect === 1;
25
- if (!selected)
26
- selected = single ? `` : [];
27
30
  if (!(options?.length > 0))
28
31
  console.error(`MultiSelect missing options`);
29
- $: invalidDisabledOptions = disabledOptions.filter((opt) => !options.includes(opt));
30
- $: if (invalidDisabledOptions.length > 0) {
31
- 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;
32
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);
33
63
  const dispatch = createEventDispatcher();
34
- let activeOption, searchText;
64
+ let searchText = ``;
35
65
  let showOptions = false;
36
- $: filteredOptions = searchText
37
- ? options.filter((option) => option.toLowerCase().includes(searchText.toLowerCase()))
38
- : options;
39
- $: 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
40
78
  (!activeOption && searchText))
41
- activeOption = filteredOptions[0];
42
- function add(token) {
79
+ // make the first filtered option active
80
+ activeOption = matchingEnabledOptions[0];
81
+ function add(label) {
43
82
  if (!readonly &&
44
- !selected.includes(token) &&
45
- // (... || single) because in single mode, we always replace current token with new selection
46
- (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)) {
47
86
  searchText = ``; // reset search string on selection
48
- selected = single ? token : [token, ...selected];
49
- if ((Array.isArray(selected) && selected.length === maxSelect) ||
50
- typeof selected === `string`) {
51
- setOptionsVisible(false);
52
- 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;
53
91
  }
54
- dispatch(`add`, { token });
55
- 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` });
56
102
  }
57
103
  }
58
- function remove(token) {
59
- if (readonly || typeof selected === `string`)
104
+ function remove(label) {
105
+ if (selected.length === 0 || readonly)
60
106
  return;
61
- selected = selected.filter((item) => item !== token);
62
- dispatch(`remove`, { token });
63
- 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` });
64
111
  }
65
112
  function setOptionsVisible(show) {
66
113
  // nothing to do if visibility is already as intended
@@ -69,83 +116,115 @@ function setOptionsVisible(show) {
69
116
  showOptions = show;
70
117
  if (show)
71
118
  input?.focus();
119
+ else {
120
+ input?.blur();
121
+ activeOption = null;
122
+ }
72
123
  }
124
+ // handle all keyboard events this component receives
73
125
  function handleKeydown(event) {
126
+ // on escape: dismiss options dropdown and reset search text
74
127
  if (event.key === `Escape`) {
75
128
  setOptionsVisible(false);
76
129
  searchText = ``;
77
130
  }
131
+ // on enter key: toggle active option and reset search text
78
132
  else if (event.key === `Enter`) {
79
133
  if (activeOption) {
80
- if (isDisabled(activeOption))
81
- return;
82
- selected.includes(activeOption) ? remove(activeOption) : add(activeOption);
134
+ const { label } = activeOption;
135
+ selectedLabels.includes(label) ? remove(label) : add(label);
83
136
  searchText = ``;
84
- } // 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
85
138
  else
86
139
  setOptionsVisible(true);
87
140
  }
141
+ // on up/down arrow keys: update active option
88
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
+ }
89
148
  const increment = event.key === `ArrowUp` ? -1 : 1;
90
- const newActiveIdx = filteredOptions.indexOf(activeOption) + increment;
149
+ const newActiveIdx = matchingEnabledOptions.indexOf(activeOption) + increment;
150
+ const ulOps = document.querySelector(`ul.options`);
91
151
  if (newActiveIdx < 0) {
92
- 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;
93
162
  }
94
163
  else {
95
- if (newActiveIdx === filteredOptions.length)
96
- 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();
97
171
  else
98
- activeOption = filteredOptions[newActiveIdx];
172
+ li?.scrollIntoViewIfNeeded();
99
173
  }
100
174
  }
101
175
  else if (event.key === `Backspace`) {
102
- // only remove selected tags on backspace if if there are any and no searchText characters remain
103
- if (selected.length > 0 && searchText.length === 0) {
104
- selected = selected.slice(0, selected.length - 1);
105
- }
176
+ const label = selectedLabels.pop();
177
+ if (label && !searchText)
178
+ remove(label);
106
179
  }
107
180
  }
108
181
  const removeAll = () => {
109
- dispatch(`remove`, { token: selected });
110
- dispatch(`change`, { token: selected, type: `remove` });
111
- selected = single ? `` : [];
182
+ dispatch(`removeAll`, { options: selected });
183
+ dispatch(`change`, { options: selected, type: `removeAll` });
184
+ selected = [];
112
185
  searchText = ``;
113
186
  };
114
- const isDisabled = (option) => disabledOptions.includes(option);
115
- $: isSelected = (option) => {
116
- if (!(selected?.length > 0))
117
- return false; // nothing is selected if `selected` is the empty array or string
118
- if (single)
119
- return selected === option;
120
- else
121
- return selected.includes(option);
187
+ $: isSelected = (label) => selectedLabels.includes(label);
188
+ const handleEnterAndSpaceKeys = (handler) => (event) => {
189
+ if ([`Enter`, `Space`].includes(event.code)) {
190
+ event.preventDefault();
191
+ handler();
192
+ }
122
193
  };
123
194
  </script>
124
195
 
125
- <!-- 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 -->
126
198
  <div
199
+ {id}
127
200
  class="multiselect {outerDivClass}"
128
201
  class:readonly
129
- class:single
130
- style={showOptions ? `z-index: 2;` : ``}
131
- on:mouseup|stopPropagation={() => setOptionsVisible(true)}>
132
- <ExpandIcon height="14pt" style="padding-left: 1pt;" />
133
- <ul class="tokens {ulTokensClass}">
134
- {#if single}
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
+ {#if maxSelect == 1 && selected[0]?.label}
135
211
  <span on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}>
136
- {selected}
212
+ {selected[0].label}
137
213
  </span>
138
- {:else if selected?.length > 0}
139
- {#each selected as tag}
214
+ {:else}
215
+ {#each selected as { label }}
140
216
  <li
141
- class={liTokenClass}
142
- on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}>
143
- {tag}
217
+ class={liSelectedClass}
218
+ on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
219
+ >
220
+ {label}
144
221
  {#if !readonly}
145
222
  <button
146
- on:mouseup|stopPropagation={() => remove(tag)}
223
+ on:mouseup|stopPropagation={() => remove(label)}
224
+ on:keydown={handleEnterAndSpaceKeys(() => remove(label))}
147
225
  type="button"
148
- title="{removeBtnTitle} {tag}">
226
+ title="{removeBtnTitle} {label}"
227
+ >
149
228
  <CrossIcon height="12pt" />
150
229
  </button>
151
230
  {/if}
@@ -159,19 +238,23 @@ $: isSelected = (option) => {
159
238
  on:mouseup|self|stopPropagation={() => setOptionsVisible(true)}
160
239
  on:keydown={handleKeydown}
161
240
  on:focus={() => setOptionsVisible(true)}
162
- on:blur={() => dispatch(`blur`)}
163
- on:blur={() => setOptionsVisible(false)}
164
- placeholder={selected.length ? `` : placeholder} />
241
+ {name}
242
+ placeholder={selectedLabels.length ? `` : placeholder}
243
+ />
165
244
  </ul>
166
245
  {#if readonly}
167
246
  <ReadOnlyIcon height="14pt" />
168
- {:else}
247
+ {:else if selected.length > 0}
248
+ {#if maxSelect !== null && maxSelect > 1}
249
+ <span style="padding: 0 3pt;">{maxSelectMsg(selected.length, maxSelect)}</span>
250
+ {/if}
169
251
  <button
170
252
  type="button"
171
253
  class="remove-all"
172
254
  title={removeAllTitle}
173
255
  on:mouseup|stopPropagation={removeAll}
174
- style={selected.length === 0 ? `display: none;` : ``}>
256
+ on:keydown={handleEnterAndSpaceKeys(removeAll)}
257
+ >
175
258
  <CrossIcon height="14pt" />
176
259
  </button>
177
260
  {/if}
@@ -180,20 +263,22 @@ $: isSelected = (option) => {
180
263
  <ul
181
264
  class="options {ulOptionsClass}"
182
265
  class:hidden={!showOptions}
183
- transition:fly={{ duration: 300, y: 40 }}>
184
- {#each filteredOptions as option}
266
+ transition:fly|local={{ duration: 300, y: 40 }}
267
+ >
268
+ {#each matchingOptions as { label, disabled, title = null, selectedTitle, disabledTitle = defaultDisabledTitle }}
185
269
  <li
186
270
  on:mouseup|preventDefault|stopPropagation
187
271
  on:mousedown|preventDefault|stopPropagation={() => {
188
- if (isDisabled(option)) return
189
-
190
- isSelected(option) ? remove(option) : add(option)
272
+ if (disabled) return
273
+ isSelected(label) ? remove(label) : add(label)
191
274
  }}
192
- class:selected={isSelected(option)}
193
- class:active={activeOption === option}
194
- class:disabled={isDisabled(option)}
195
- class={liOptionClass}>
196
- {option}
275
+ class:selected={isSelected(label)}
276
+ class:active={activeOption?.label === label}
277
+ class:disabled
278
+ title={disabled ? disabledTitle : (isSelected(label) && selectedTitle) || title}
279
+ class={liOptionClass}
280
+ >
281
+ {label}
197
282
  </li>
198
283
  {:else}
199
284
  {noOptionsMsg}
@@ -212,6 +297,7 @@ $: isSelected = (option) => {
212
297
  min-height: 18pt;
213
298
  display: flex;
214
299
  cursor: text;
300
+ padding: 0 3pt;
215
301
  }
216
302
  :where(.multiselect:focus-within) {
217
303
  border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
@@ -220,8 +306,8 @@ $: isSelected = (option) => {
220
306
  background: var(--sms-readonly-bg, lightgray);
221
307
  }
222
308
 
223
- :where(ul.tokens > li) {
224
- background: var(--sms-token-bg, var(--sms-active-color, cornflowerblue));
309
+ :where(ul.selected > li) {
310
+ background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue));
225
311
  align-items: center;
226
312
  border-radius: 4pt;
227
313
  display: flex;
@@ -231,16 +317,13 @@ $: isSelected = (option) => {
231
317
  white-space: nowrap;
232
318
  height: 16pt;
233
319
  }
234
- :where(ul.tokens > li button, button.remove-all) {
320
+ :where(ul.selected > li button, button.remove-all) {
235
321
  align-items: center;
236
322
  border-radius: 50%;
237
323
  display: flex;
238
324
  cursor: pointer;
239
325
  transition: 0.2s;
240
326
  }
241
- :where(ul.tokens > li button:hover, button.remove-all:hover) {
242
- color: var(--sms-remove-x-hover-color, lightgray);
243
- }
244
327
  :where(button) {
245
328
  color: inherit;
246
329
  background: transparent;
@@ -249,23 +332,30 @@ $: isSelected = (option) => {
249
332
  outline: none;
250
333
  padding: 0 2pt;
251
334
  }
335
+ :where(ul.selected > li button:hover, button.remove-all:hover) {
336
+ color: var(--sms-remove-x-hover-focus-color, lightskyblue);
337
+ }
338
+ :where(button:focus) {
339
+ color: var(--sms-remove-x-hover-focus-color, lightskyblue);
340
+ transform: scale(1.04);
341
+ }
252
342
 
253
343
  :where(.multiselect input) {
254
344
  border: none;
255
345
  outline: none;
256
346
  background: none;
257
- /* needed to hide red shadow around required inputs in some browsers */
258
- box-shadow: none;
259
347
  color: var(--sms-text-color, inherit);
260
- flex: 1;
348
+ flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
349
+ min-width: 2em;
261
350
  }
262
351
 
263
- :where(ul.tokens) {
352
+ :where(ul.selected) {
264
353
  display: flex;
265
354
  padding: 0;
266
355
  margin: 0;
267
356
  flex-wrap: wrap;
268
357
  flex: 1;
358
+ overscroll-behavior: none;
269
359
  }
270
360
 
271
361
  :where(ul.options) {
@@ -1,21 +1,28 @@
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;
14
+ name?: string | undefined;
15
+ id?: string | undefined;
11
16
  noOptionsMsg?: string | undefined;
17
+ activeOption?: Option | null | undefined;
12
18
  outerDivClass?: string | undefined;
13
- ulTokensClass?: string | undefined;
14
- liTokenClass?: string | undefined;
19
+ ulSelectedClass?: string | undefined;
20
+ liSelectedClass?: string | undefined;
15
21
  ulOptionsClass?: string | undefined;
16
22
  liOptionClass?: string | undefined;
17
23
  removeBtnTitle?: string | undefined;
18
24
  removeAllTitle?: string | undefined;
25
+ defaultDisabledTitle?: string | undefined;
19
26
  };
20
27
  events: {
21
28
  mouseup: MouseEvent;
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,35 @@
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.1",
8
+ "version": "3.0.0",
9
9
  "type": "module",
10
10
  "svelte": "MultiSelect.svelte",
11
11
  "bugs": {
12
12
  "url": "https://github.com/janosh/svelte-multiselect/issues"
13
13
  },
14
14
  "devDependencies": {
15
- "@sveltejs/adapter-static": "^1.0.0-next.20",
16
- "@sveltejs/kit": "^1.0.0-next.180",
17
- "@typescript-eslint/eslint-plugin": "^5.0.0",
18
- "@typescript-eslint/parser": "^5.0.0",
19
- "eslint": "^8.0.0",
15
+ "@sveltejs/adapter-static": "^1.0.0-next.22",
16
+ "@sveltejs/kit": "^1.0.0-next.202",
17
+ "@testing-library/svelte": "^3.0.3",
18
+ "@typescript-eslint/eslint-plugin": "^5.7.0",
19
+ "@typescript-eslint/parser": "^5.7.0",
20
+ "ava": "^3.15.0",
21
+ "eslint": "^8.5.0",
20
22
  "eslint-plugin-svelte3": "^3.2.1",
21
23
  "hastscript": "^7.0.2",
22
24
  "mdsvex": "^0.9.8",
23
- "prettier": "^2.4.1",
24
- "prettier-plugin-svelte": "^2.4.0",
25
+ "prettier": "^2.5.1",
26
+ "prettier-plugin-svelte": "^2.5.1",
25
27
  "rehype-autolink-headings": "^6.1.0",
26
28
  "rehype-slug": "^5.0.0",
27
- "svelte": "^3.43.1",
28
- "svelte-check": "^2.2.7",
29
- "svelte-preprocess": "^4.9.8",
30
- "svelte-toc": "^0.1.8",
31
- "svelte2tsx": "^0.4.7",
29
+ "svelte": "^3.44.3",
30
+ "svelte-check": "^2.2.11",
31
+ "svelte-preprocess": "^4.10.1",
32
+ "svelte-toc": "^0.1.10",
33
+ "svelte2tsx": "^0.4.12",
32
34
  "tslib": "^2.3.1",
33
- "typescript": "^4.4.3",
34
- "vite": "^2.6.7"
35
+ "typescript": "^4.5.4",
36
+ "vite": "^2.7.3"
35
37
  },
36
38
  "keywords": [
37
39
  "svelte",
@@ -46,6 +48,7 @@
46
48
  "exports": {
47
49
  "./package.json": "./package.json",
48
50
  "./MultiSelect.svelte": "./MultiSelect.svelte",
51
+ "./actions": "./actions.js",
49
52
  ".": "./index.js"
50
53
  }
51
54
  }
package/readme.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  <!-- remove above in docs -->
22
22
 
23
- Keyboard-friendly, zero-dependency multi-select Svelte component.
23
+ **Keyboard-friendly, zero-dependency multi-select Svelte component.**
24
24
 
25
25
  ## Key Features
26
26
 
@@ -29,10 +29,22 @@ Keyboard-friendly, zero-dependency multi-select Svelte component.
29
29
  - **Searchable:** start typing to filter options
30
30
  - **Tagging:** selected options are recorded as tags within the text input
31
31
  - **Server-side rendering:** no reliance on browser objects like `window` or `document`
32
- - **Configurable:** see section [props](#props)
32
+ - **Configurable:** see [props](#props)
33
33
  - **No dependencies:** needs only Svelte as dev dependency
34
34
  - **Keyboard friendly** for mouse-less form completion
35
35
 
36
+ > ## Recent breaking changes
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 as well, even if you still pass in `options` as strings.
39
+ > - v3.0.0 changed the `event.detail` payload for `'add'`, `'remove'` and `'change'` events from `token` to `option`, e.g.
40
+ >
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
44
+ > ```
45
+ >
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
+
36
48
  ## Installation
37
49
 
38
50
  ```sh
@@ -64,7 +76,7 @@ yarn add -D svelte-multiselect
64
76
 
65
77
  Favorite Web Frameworks?
66
78
 
67
- {JSON.stringify(selected, null, 2)}
79
+ <code>selected = {JSON.stringify(selected)}</code>
68
80
 
69
81
  <MultiSelect bind:selected options={webFrameworks} />
70
82
  ```
@@ -75,17 +87,21 @@ Full list of props/bindable variables for this component:
75
87
 
76
88
  <div class="table">
77
89
 
78
- | name | default | description |
79
- | :---------------- | :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
80
- | `options` | [required] | 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 input. User won't be able to interact with it. |
84
- | `placeholder` | `''` | String shown when no option is selected. |
85
- | `disabledOptions` | `[]` | Array of strings that will be disabled in the dropdown selection. |
86
- | `required` | `false` | Prevents submission in an HTML form when true. |
87
- | `input` | `undefined` | Handle to the DOM node storing the currently selected options in JSON format as its `value` attribute. |
88
- | `name` | `''` | Used as `name` reference for associating HTML form `<label>`s with this component as well as for the `<input>`'s `id`. That is, the same DOM node bindable through `<MultiSelect bind:input />` is also retrievable via `document.getElementByID(name)` e.g. for use in a JS file outside a Svelte component. |
90
+ <!-- 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
+ | `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. |
104
+ | `id` | `undefined` | Applied to the top-level `<div>` e.g. for `document.getElementById()`. |
89
105
 
90
106
  </div>
91
107
 
@@ -93,34 +109,26 @@ Full list of props/bindable variables for this component:
93
109
 
94
110
  `MultiSelect.svelte` dispatches the following events:
95
111
 
96
- | name | details | description |
97
- | -------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
98
- | `add` | `token: string` | Triggers when a new token is selected. |
99
- | `remove` | `token: string` | Triggers when one or all selected tokens are removed. `event.detail.token` will be a single or multiple tokens, respectively. |
100
- | `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'`. |
101
- | `blur` | none | Triggers when the input field looses focus. |
112
+ | name | detail | description |
113
+ | ----------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
114
+ | `add` | `{ option: Option }` | Triggers when a new option is selected. |
115
+ | `remove` | `{ option: Option }` | Triggers when one selected option provided as `event.detail.option` is removed. |
116
+ | `removeAll` | `options: Option[]` | Triggers when all selected options are removed. The payload `event.detail.options` gives the options that were previously selected. |
117
+ | `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. |
118
+ | `blur` | none | Triggers when the input field looses focus. |
102
119
 
103
120
  ### Examples
104
121
 
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}'`)} ``
122
+ <!-- prettier-ignore -->
123
+ - `on:add={(event) => console.log(event.detail.option.label)}`
124
+ - `on:remove={(event) => console.log(event.detail.option.label)}`.
125
+ - ``on:change={(event) => console.log(`${event.detail.type}: '${event.detail.option.label}'`)}``
108
126
  - `on:blur={yourFunctionHere}`
109
127
 
110
128
  ```svelte
111
129
  <MultiSelect
112
- on:change={(e) => alert(`You ${e.detail.type}ed '${e.detail.token}'`)} />
113
- ```
114
-
115
- ## Want to contribute?
116
-
117
- To submit a PR, clone the repo, install dependencies and start the dev server to try out your changes first.
118
-
119
- ```sh
120
- git clone https://github.com/janosh/svelte-multiselect
121
- cd svelte-multiselect
122
- yarn
123
- yarn dev
130
+ on:change={(e) => alert(`You ${e.detail.type}ed '${e.detail.option.label}'`)}
131
+ />
124
132
  ```
125
133
 
126
134
  ## Styling
@@ -134,10 +142,11 @@ The first, if you only want to make small adjustments, allows you to pass the fo
134
142
  - `border: var(--sms-border, 1pt solid lightgray)`: Border around top-level `div.multiselect`. Change this to e.g. to `1px solid red` to indicate this form field is in an invalid state.
135
143
  - `border-radius: var(--sms-border-radius, 5pt)`: `div.multiselect` border radius.
136
144
  - `color: var(--sms-text-color, inherit)`: Input text color.
137
- - `border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue))`: `div.multiselect` border when focused.
145
+ - `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
146
  - `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-color, lightgray)`: Hover color of cross icon to remove selected tokens.
147
+ - `background: var(--sms-selected-bg, var(--sms-active-color, cornflowerblue))`: Background of selected options.
148
+ - `color: var(--sms-remove-x-hover+focus-color, lightgray)`: Hover color of cross icon to remove selected options.
149
+ - `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.
141
150
  - `background: var(--sms-options-bg, white)`: Background of options list.
142
151
  - `background: var(--sms-li-selected-bg, inherit)`: Background of selected list items in options pane.
143
152
  - `color: var(--sms-li-selected-color, inherit)`: Text color of selected list items in options pane.
@@ -156,8 +165,8 @@ For example, to change the background color of the options dropdown:
156
165
  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).
157
166
 
158
167
  - `outerDivClass`
159
- - `ulTokensClass`
160
- - `liTokenClass`
168
+ - `ulSelectedClass`
169
+ - `liSelectedClass`
161
170
  - `ulOptionsClass`
162
171
  - `liOptionClass`
163
172
 
@@ -165,9 +174,9 @@ This simplified version of the DOM structure of this component shows where these
165
174
 
166
175
  ```svelte
167
176
  <div class={outerDivClass}>
168
- <ul class={ulTokensClass}>
169
- <li class={liTokenClass}>First selected tag</li>
170
- <li class={liTokenClass}>Second selected tag</li>
177
+ <ul class={ulSelectedClass}>
178
+ <li class={liSelectedClass}>First selected tag</li>
179
+ <li class={liSelectedClass}>Second selected tag</li>
171
180
  </ul>
172
181
  <ul class={ulOptionsClass}>
173
182
  <li class={liOptionClass}>First available option</li>
@@ -178,16 +187,16 @@ This simplified version of the DOM structure of this component shows where these
178
187
 
179
188
  ### Granular control through global CSS
180
189
 
181
- You can alternatively style every part of this component with more fine-grained control by using the following `:global()` CSS selectors.
190
+ 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.
182
191
 
183
192
  ```css
184
193
  :global(.multiselect) {
185
194
  /* top-level wrapper div */
186
195
  }
187
- :global(.multiselect ul.tokens > li) {
188
- /* the blue tags representing selected options with remove buttons inside the input */
196
+ :global(.multiselect ul.selected > li) {
197
+ /* selected options */
189
198
  }
190
- :global(.multiselect ul.tokens > li button),
199
+ :global(.multiselect ul.selected > li button),
191
200
  :global(.multiselect button.remove-all) {
192
201
  /* buttons to remove a single or all selected options at once */
193
202
  }
@@ -195,22 +204,37 @@ You can alternatively style every part of this component with more fine-grained
195
204
  /* dropdown options */
196
205
  }
197
206
  :global(.multiselect ul.options li) {
198
- /* dropdown options */
207
+ /* dropdown list of available options */
199
208
  }
200
- :global(ul.options li.selected) {
209
+ :global(.multiselect ul.options li.selected) {
201
210
  /* selected options in the dropdown list */
202
211
  }
203
- :global(ul.options li:not(.selected):hover) {
212
+ :global(.multiselect ul.options li:not(.selected):hover) {
204
213
  /* unselected but hovered options in the dropdown list */
205
214
  }
206
- :global(ul.options li.selected:hover) {
215
+ :global(.multiselect ul.options li.selected:hover) {
207
216
  /* selected and hovered options in the dropdown list */
208
217
  /* probably not necessary to style this state in most cases */
209
218
  }
210
- :global(ul.options li.active) {
211
- /* active means element was navigated to with up/down arrow keys */
219
+ :global(.multiselect ul.options li.active) {
220
+ /* active means item was navigated to with up/down arrow keys */
212
221
  /* ready to be selected by pressing enter */
213
222
  }
214
- :global(ul.options li.selected.active) {
223
+ :global(.multiselect ul.options li.selected.active) {
224
+ /* both active and already selected, pressing enter now will deselect the item */
215
225
  }
226
+ :global(.multiselect ul.options li.disabled) {
227
+ /* options with disabled key set to true (see props above) */
228
+ }
229
+ ```
230
+
231
+ ## Want to contribute?
232
+
233
+ To submit a PR, clone the repo, install dependencies and start the dev server to try out your changes.
234
+
235
+ ```sh
236
+ git clone https://github.com/janosh/svelte-multiselect
237
+ cd svelte-multiselect
238
+ yarn
239
+ yarn dev
216
240
  ```