|
54 | 54 | export let required: boolean = false |
55 | 55 | export let resetFilterOnAdd: boolean = true |
56 | 56 | export let searchText: string = `` |
57 | | - export let selected: Option[] | Option | null = |
| 57 | + export let selected: Option[] = |
58 | 58 | options |
59 | 59 | ?.filter((op) => (op as ObjectOption)?.preselected) |
60 | 60 | .slice(0, maxSelect ?? undefined) ?? [] |
61 | 61 | export let sortSelected: boolean | ((op1: Option, op2: Option) => number) = false |
62 | 62 | export let ulOptionsClass: string = `` |
63 | 63 | export let ulSelectedClass: string = `` |
| 64 | + export let value: Option | Option[] | null = null |
64 | 65 |
|
65 | 66 | // get the label key from an option object or the option itself if it's a string or number |
66 | 67 | const get_label = (op: Option) => (op instanceof Object ? op.label : op) |
67 | 68 |
|
68 | | - // selected and _selected are identical except if maxSelect=1, selected will be the single item (or null) |
69 | | - // in _selected which will always be an array for easier component internals. selected then solves |
70 | | - // https://github.com/janosh/svelte-multiselect/issues/86 |
71 | | - let _selected = (selected ?? []) as Option[] |
72 | | - $: selected = maxSelect === 1 ? _selected[0] ?? null : _selected |
| 69 | + // if maxSelect=1, value is the single item in selected (or null if selected is empty) |
| 70 | + // this solves both https://github.com/janosh/svelte-multiselect/issues/86 and |
| 71 | + // https://github.com/janosh/svelte-multiselect/issues/136 |
| 72 | + $: value = maxSelect === 1 ? selected[0] ?? null : selected |
73 | 73 |
|
74 | 74 | let wiggle = false // controls wiggle animation when user tries to exceed maxSelect |
75 | 75 |
|
|
91 | 91 | if (maxSelect !== null && maxSelect < 1) { |
92 | 92 | console.error(`maxSelect must be null or positive integer, got ${maxSelect}`) |
93 | 93 | } |
94 | | - if (!Array.isArray(_selected)) { |
| 94 | + if (!Array.isArray(selected)) { |
95 | 95 | console.error( |
96 | | - `internal variable _selected prop should always be an array, got ${_selected}` |
| 96 | + `internal variable selected prop should always be an array, got ${selected}` |
97 | 97 | ) |
98 | 98 | } |
99 | 99 |
|
|
103 | 103 |
|
104 | 104 | // options matching the current search text |
105 | 105 | $: matchingOptions = options.filter( |
106 | | - (op) => |
107 | | - filterFunc(op, searchText) && !_selected.map(get_label).includes(get_label(op)) // remove already selected options from dropdown list |
| 106 | + (op) => filterFunc(op, searchText) && !selected.map(get_label).includes(get_label(op)) // remove already selected options from dropdown list |
108 | 107 | ) |
109 | 108 | // raise if matchingOptions[activeIndex] does not yield a value |
110 | 109 | if (activeIndex !== null && !matchingOptions[activeIndex]) { |
|
115 | 114 |
|
116 | 115 | // add an option to selected list |
117 | 116 | function add(label: string | number, event: Event) { |
118 | | - if (maxSelect && maxSelect > 1 && _selected.length >= maxSelect) wiggle = true |
119 | | - if (!isNaN(Number(label)) && typeof _selected.map(get_label)[0] === `number`) |
| 117 | + if (maxSelect && maxSelect > 1 && selected.length >= maxSelect) wiggle = true |
| 118 | + if (!isNaN(Number(label)) && typeof selected.map(get_label)[0] === `number`) |
120 | 119 | label = Number(label) // convert to number if possible |
121 | 120 |
|
122 | | - const is_duplicate = _selected.some((option) => duplicateFunc(option, label)) |
| 121 | + const is_duplicate = selected.some((option) => duplicateFunc(option, label)) |
123 | 122 | if ( |
124 | | - (maxSelect === null || maxSelect === 1 || _selected.length < maxSelect) && |
| 123 | + (maxSelect === null || maxSelect === 1 || selected.length < maxSelect) && |
125 | 124 | (duplicates || !is_duplicate) |
126 | 125 | ) { |
127 | 126 | // first check if we find option in the options list |
|
161 | 160 | } |
162 | 161 | if (maxSelect === 1) { |
163 | 162 | // for maxselect = 1 we always replace current option with new one |
164 | | - _selected = [option] |
| 163 | + selected = [option] |
165 | 164 | } else { |
166 | | - _selected = [..._selected, option] |
| 165 | + selected = [...selected, option] |
167 | 166 | if (sortSelected === true) { |
168 | | - _selected = _selected.sort((op1: Option, op2: Option) => { |
| 167 | + selected = selected.sort((op1: Option, op2: Option) => { |
169 | 168 | const [label1, label2] = [get_label(op1), get_label(op2)] |
170 | 169 | // coerce to string if labels are numbers |
171 | 170 | return `${label1}`.localeCompare(`${label2}`) |
172 | 171 | }) |
173 | 172 | } else if (typeof sortSelected === `function`) { |
174 | | - _selected = _selected.sort(sortSelected) |
| 173 | + selected = selected.sort(sortSelected) |
175 | 174 | } |
176 | 175 | } |
177 | | - if (_selected.length === maxSelect) close_dropdown(event) |
| 176 | + if (selected.length === maxSelect) close_dropdown(event) |
178 | 177 | else if ( |
179 | 178 | focusInputOnSelect === true || |
180 | 179 | (focusInputOnSelect === `desktop` && window_width > breakpoint) |
|
190 | 189 |
|
191 | 190 | // remove an option from selected list |
192 | 191 | function remove(label: string | number) { |
193 | | - if (_selected.length === 0) return |
| 192 | + if (selected.length === 0) return |
194 | 193 |
|
195 | | - _selected.splice(_selected.map(get_label).lastIndexOf(label), 1) |
196 | | - _selected = _selected // Svelte rerender after in-place splice |
| 194 | + selected.splice(selected.map(get_label).lastIndexOf(label), 1) |
| 195 | + selected = selected // Svelte rerender after in-place splice |
197 | 196 |
|
198 | 197 | const option = |
199 | 198 | options.find((option) => get_label(option) === label) ?? |
|
241 | 240 |
|
242 | 241 | if (activeOption) { |
243 | 242 | const label = get_label(activeOption) |
244 | | - _selected.map(get_label).includes(label) ? remove(label) : add(label, event) |
| 243 | + selected.map(get_label).includes(label) ? remove(label) : add(label, event) |
245 | 244 | searchText = `` |
246 | 245 | } else if (allowUserOptions && searchText.length > 0) { |
247 | 246 | // user entered text but no options match, so if allowUserOptions is truthy, we create new option |
|
285 | 284 | } |
286 | 285 | } |
287 | 286 | // on backspace key: remove last selected option |
288 | | - else if (event.key === `Backspace` && _selected.length > 0 && !searchText) { |
289 | | - remove(_selected.map(get_label).at(-1) as string | number) |
| 287 | + else if (event.key === `Backspace` && selected.length > 0 && !searchText) { |
| 288 | + remove(selected.map(get_label).at(-1) as string | number) |
290 | 289 | } |
291 | 290 | } |
292 | 291 |
|
293 | 292 | function remove_all() { |
294 | | - dispatch(`removeAll`, { options: _selected }) |
295 | | - dispatch(`change`, { options: _selected, type: `removeAll` }) |
296 | | - _selected = [] |
| 293 | + dispatch(`removeAll`, { options: selected }) |
| 294 | + dispatch(`change`, { options: selected, type: `removeAll` }) |
| 295 | + selected = [] |
297 | 296 | searchText = `` |
298 | 297 | } |
299 | 298 |
|
300 | | - $: is_selected = (label: string | number) => _selected.map(get_label).includes(label) |
| 299 | + $: is_selected = (label: string | number) => selected.map(get_label).includes(label) |
301 | 300 |
|
302 | 301 | const if_enter_or_space = (handler: () => void) => (event: KeyboardEvent) => { |
303 | 302 | if ([`Enter`, `Space`].includes(event.code)) { |
|
332 | 331 | title={disabled ? disabledInputTitle : null} |
333 | 332 | aria-disabled={disabled ? `true` : null} |
334 | 333 | > |
335 | | - <!-- bind:value={_selected} prevents form submission if required prop is true and no options are selected --> |
| 334 | + <!-- bind:value={selected} prevents form submission if required prop is true and no options are selected --> |
336 | 335 | <input |
337 | 336 | {required} |
338 | | - bind:value={_selected} |
| 337 | + bind:value={selected} |
339 | 338 | tabindex="-1" |
340 | 339 | aria-hidden="true" |
341 | 340 | aria-label="ignore this, used only to prevent form submission if select is required but empty" |
|
344 | 343 | /> |
345 | 344 | <ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt;" /> |
346 | 345 | <ul class="selected {ulSelectedClass}"> |
347 | | - {#each _selected as option, idx} |
| 346 | + {#each selected as option, idx} |
348 | 347 | <li class={liSelectedClass} aria-selected="true"> |
349 | 348 | <slot name="selected" {option} {idx}> |
350 | 349 | {#if parseLabelsAsHtml} |
|
382 | 381 | {disabled} |
383 | 382 | {inputmode} |
384 | 383 | {pattern} |
385 | | - placeholder={_selected.length == 0 ? placeholder : null} |
| 384 | + placeholder={selected.length == 0 ? placeholder : null} |
386 | 385 | aria-invalid={invalid ? `true` : null} |
387 | 386 | on:blur |
388 | 387 | on:change |
|
409 | 408 | <slot name="disabled-icon"> |
410 | 409 | <DisabledIcon width="15px" /> |
411 | 410 | </slot> |
412 | | - {:else if _selected.length > 0} |
| 411 | + {:else if selected.length > 0} |
413 | 412 | {#if maxSelect && (maxSelect > 1 || maxSelectMsg)} |
414 | 413 | <Wiggle bind:wiggle angle={20}> |
415 | 414 | <span style="padding: 0 3pt;"> |
416 | | - {maxSelectMsg?.(_selected.length, maxSelect)} |
| 415 | + {maxSelectMsg?.(selected.length, maxSelect)} |
417 | 416 | </span> |
418 | 417 | </Wiggle> |
419 | 418 | {/if} |
420 | | - {#if maxSelect !== 1 && _selected.length > 1} |
| 419 | + {#if maxSelect !== 1 && selected.length > 1} |
421 | 420 | <button |
422 | 421 | type="button" |
423 | 422 | class="remove-all" |
|
487 | 486 | on:blur={() => (add_option_msg_is_active = false)} |
488 | 487 | aria-selected="false" |
489 | 488 | > |
490 | | - {!duplicates && _selected.some((option) => duplicateFunc(option, searchText)) |
| 489 | + {!duplicates && selected.some((option) => duplicateFunc(option, searchText)) |
491 | 490 | ? duplicateOptionMsg |
492 | 491 | : addOptionMsg} |
493 | 492 | </li> |
|
0 commit comments