|
24 | 24 | export let id: string | null = null |
25 | 25 | export let input: HTMLInputElement | null = null |
26 | 26 | export let inputClass: string = `` |
| 27 | + export let inputmode: string | null = null |
27 | 28 | export let invalid: boolean = false |
28 | 29 | export let liActiveOptionClass: string = `` |
29 | 30 | export let liOptionClass: string = `` |
|
39 | 40 | export let outerDiv: HTMLDivElement | null = null |
40 | 41 | export let outerDivClass: string = `` |
41 | 42 | export let parseLabelsAsHtml: boolean = false // should not be combined with allowUserOptions! |
| 43 | + export let pattern: string | null = null |
42 | 44 | export let placeholder: string | null = null |
43 | 45 | export let removeAllTitle: string = `Remove all` |
44 | 46 | export let removeBtnTitle: string = `Remove` |
45 | 47 | export let required: boolean = false |
46 | 48 | export let searchText: string = `` |
47 | | - export let selected: Option[] = |
48 | | - options?.filter((op) => (op as ObjectOption)?.preselected) ?? [] |
49 | | - export let selectedLabels: (string | number)[] = [] |
50 | | - export let selectedValues: unknown[] = [] |
| 49 | + export let selected: Option[] | Option | null = |
| 50 | + options |
| 51 | + ?.filter((op) => (op as ObjectOption)?.preselected) |
| 52 | + .slice(0, maxSelect ?? undefined) ?? [] |
| 53 | + export let selectedLabels: (string | number)[] | string | number | null = [] |
| 54 | + export let selectedValues: unknown[] | unknown | null = [] |
51 | 55 | export let sortSelected: boolean | ((op1: Option, op2: Option) => number) = false |
52 | 56 | export let ulOptionsClass: string = `` |
53 | 57 | export let ulSelectedClass: string = `` |
54 | | - export let inputmode: string = `` |
55 | | - export let pattern: string = `` |
| 58 | +
|
| 59 | + // selected and _selected are identical except if maxSelect=1, selected will be the single item (or null) |
| 60 | + // in _selected which will always be an array for easier component internals. selected then solves |
| 61 | + // https://github.com/janosh/svelte-multiselect/issues/86 |
| 62 | + let _selected = (selected ?? []) as Option[] |
| 63 | + $: selected = maxSelect === 1 ? _selected[0] ?? null : _selected |
| 64 | +
|
| 65 | + let wiggle = false // controls wiggle animation when user tries to exceed maxSelect |
| 66 | + $: _selectedLabels = _selected?.map(get_label) ?? [] |
| 67 | + $: selectedLabels = maxSelect === 1 ? _selectedLabels[0] ?? null : _selectedLabels |
| 68 | + $: _selectedValues = _selected?.map(get_value) ?? [] |
| 69 | + $: selectedValues = maxSelect === 1 ? _selectedValues[0] ?? null : _selectedValues |
56 | 70 |
|
57 | 71 | type $$Events = MultiSelectEvents // for type-safe event listening on this component |
58 | 72 |
|
|
72 | 86 | if (maxSelect !== null && maxSelect < 1) { |
73 | 87 | console.error(`maxSelect must be null or positive integer, got ${maxSelect}`) |
74 | 88 | } |
75 | | - if (!Array.isArray(selected)) { |
76 | | - console.error(`selected prop must be an array, got ${selected}`) |
| 89 | + if (!Array.isArray(_selected)) { |
| 90 | + console.error( |
| 91 | + `internal variable _selected prop should always be an array, got ${_selected}` |
| 92 | + ) |
77 | 93 | } |
78 | 94 |
|
79 | 95 | const dispatch = createEventDispatcher<DispatchEvents>() |
80 | 96 | let add_option_msg_is_active: boolean = false // controls active state of <li>{addOptionMsg}</li> |
81 | 97 | let window_width: number |
82 | 98 |
|
83 | | - let wiggle = false // controls wiggle animation when user tries to exceed maxSelect |
84 | | - $: selectedLabels = selected?.map(get_label) ?? [] |
85 | | - $: selectedValues = selected?.map(get_value) ?? [] |
86 | | -
|
87 | 99 | // formValue binds to input.form-control to prevent form submission if required |
88 | 100 | // prop is true and no options are selected |
89 | | - $: formValue = selectedValues.join(`,`) |
| 101 | + $: formValue = _selectedValues.join(`,`) |
90 | 102 | $: if (formValue) invalid = false // reset error status whenever component state changes |
91 | 103 |
|
92 | 104 | // options matching the current search text |
93 | 105 | $: matchingOptions = options.filter( |
94 | | - (op) => filterFunc(op, searchText) && !selectedLabels.includes(get_label(op)) // remove already selected options from dropdown list |
| 106 | + (op) => filterFunc(op, searchText) && !_selectedLabels.includes(get_label(op)) // remove already selected options from dropdown list |
95 | 107 | ) |
96 | 108 | // raise if matchingOptions[activeIndex] does not yield a value |
97 | 109 | if (activeIndex !== null && !matchingOptions[activeIndex]) { |
|
102 | 114 |
|
103 | 115 | // add an option to selected list |
104 | 116 | function add(label: string | number, event: Event) { |
105 | | - if (maxSelect && maxSelect > 1 && selected.length >= maxSelect) wiggle = true |
| 117 | + if (maxSelect && maxSelect > 1 && _selected.length >= maxSelect) wiggle = true |
106 | 118 | // to prevent duplicate selection, we could add `&& !selectedLabels.includes(label)` |
107 | | - if (maxSelect === null || maxSelect === 1 || selected.length < maxSelect) { |
| 119 | + if (maxSelect === null || maxSelect === 1 || _selected.length < maxSelect) { |
108 | 120 | // first check if we find option in the options list |
109 | 121 |
|
110 | 122 | let option = options.find((op) => get_label(op) === label) |
|
137 | 149 | } |
138 | 150 | if (maxSelect === 1) { |
139 | 151 | // for maxselect = 1 we always replace current option with new one |
140 | | - selected = [option] |
| 152 | + _selected = [option] |
141 | 153 | } else { |
142 | | - selected = [...selected, option] |
| 154 | + _selected = [..._selected, option] |
143 | 155 | if (sortSelected === true) { |
144 | | - selected = selected.sort((op1: Option, op2: Option) => { |
| 156 | + _selected = _selected.sort((op1: Option, op2: Option) => { |
145 | 157 | const [label1, label2] = [get_label(op1), get_label(op2)] |
146 | 158 | // coerce to string if labels are numbers |
147 | 159 | return `${label1}`.localeCompare(`${label2}`) |
148 | 160 | }) |
149 | 161 | } else if (typeof sortSelected === `function`) { |
150 | | - selected = selected.sort(sortSelected) |
| 162 | + _selected = _selected.sort(sortSelected) |
151 | 163 | } |
152 | 164 | } |
153 | | - if (selected.length === maxSelect) close_dropdown(event) |
| 165 | + if (_selected.length === maxSelect) close_dropdown(event) |
154 | 166 | else if ( |
155 | 167 | focusInputOnSelect === true || |
156 | 168 | (focusInputOnSelect === `desktop` && window_width > breakpoint) |
|
164 | 176 |
|
165 | 177 | // remove an option from selected list |
166 | 178 | function remove(label: string | number) { |
167 | | - if (selected.length === 0) return |
| 179 | + if (_selected.length === 0) return |
168 | 180 |
|
169 | | - selected.splice(selectedLabels.lastIndexOf(label), 1) |
170 | | - selected = selected // Svelte rerender after in-place splice |
| 181 | + _selected.splice(_selectedLabels.lastIndexOf(label), 1) |
| 182 | + _selected = _selected // Svelte rerender after in-place splice |
171 | 183 |
|
172 | 184 | const option = |
173 | 185 | options.find((option) => get_label(option) === label) ?? |
|
259 | 271 | } |
260 | 272 | } |
261 | 273 | // on backspace key: remove last selected option |
262 | | - else if (event.key === `Backspace` && selectedLabels.length > 0 && !searchText) { |
263 | | - remove(selectedLabels.at(-1) as string | number) |
| 274 | + else if (event.key === `Backspace` && _selectedLabels.length > 0 && !searchText) { |
| 275 | + remove(_selectedLabels.at(-1) as string | number) |
264 | 276 | } |
265 | 277 | } |
266 | 278 |
|
267 | 279 | function remove_all() { |
268 | | - dispatch(`removeAll`, { options: selected }) |
269 | | - dispatch(`change`, { options: selected, type: `removeAll` }) |
270 | | - selected = [] |
| 280 | + dispatch(`removeAll`, { options: _selected }) |
| 281 | + dispatch(`change`, { options: _selected, type: `removeAll` }) |
| 282 | + _selected = [] |
271 | 283 | searchText = `` |
272 | 284 | } |
273 | 285 |
|
274 | | - $: is_selected = (label: string | number) => selectedLabels.includes(label) |
| 286 | + $: is_selected = (label: string | number) => _selectedLabels.includes(label) |
275 | 287 |
|
276 | 288 | const if_enter_or_space = (handler: () => void) => (event: KeyboardEvent) => { |
277 | 289 | if ([`Enter`, `Space`].includes(event.code)) { |
|
317 | 329 | /> |
318 | 330 | <ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt;" /> |
319 | 331 | <ul class="selected {ulSelectedClass}"> |
320 | | - {#each selected as option, idx} |
| 332 | + {#each _selected as option, idx} |
321 | 333 | <li class={liSelectedClass} aria-selected="true"> |
322 | 334 | <slot name="selected" {option} {idx}> |
323 | 335 | {#if parseLabelsAsHtml} |
|
353 | 365 | {disabled} |
354 | 366 | {inputmode} |
355 | 367 | {pattern} |
356 | | - placeholder={selectedLabels.length ? `` : placeholder} |
| 368 | + placeholder={_selected.length == 0 ? placeholder : null} |
357 | 369 | aria-invalid={invalid ? `true` : null} |
358 | 370 | on:blur |
359 | 371 | on:change |
|
380 | 392 | <slot name="disabled-icon"> |
381 | 393 | <DisabledIcon width="15px" /> |
382 | 394 | </slot> |
383 | | - {:else if selected.length > 0} |
| 395 | + {:else if _selected.length > 0} |
384 | 396 | {#if maxSelect && (maxSelect > 1 || maxSelectMsg)} |
385 | 397 | <Wiggle bind:wiggle angle={20}> |
386 | 398 | <span style="padding: 0 3pt;"> |
387 | | - {maxSelectMsg?.(selected.length, maxSelect) ?? |
388 | | - (maxSelect > 1 ? `${selected.length}/${maxSelect}` : ``)} |
| 399 | + {maxSelectMsg?.(_selected.length, maxSelect) ?? |
| 400 | + (maxSelect > 1 ? `${_selected.length}/${maxSelect}` : ``)} |
389 | 401 | </span> |
390 | 402 | </Wiggle> |
391 | 403 | {/if} |
392 | | - {#if maxSelect !== 1 && selected.length > 1} |
| 404 | + {#if maxSelect !== 1 && _selected.length > 1} |
393 | 405 | <button |
394 | 406 | type="button" |
395 | 407 | class="remove-all" |
|
0 commit comments