Skip to content

Commit ba83402

Browse files
authored
Add new prop value (#138)
* add new prop value If `maxSelect={1}`, `value` will be the single item in `selected` (or `null` if `selected` is empty). If `maxSelect != 1`, `maxSelect` and `selected` are equal. * improve test 'required but empty MultiSelect makes form not pass validity check' * add tests for '2-way bind selected' and '1-way bind value' * shorten document query selectors and tweak unit test titles
1 parent ef6598e commit ba83402

File tree

4 files changed

+166
-85
lines changed

4 files changed

+166
-85
lines changed

readme.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,20 +309,26 @@ import type { Option } from 'svelte-multiselect'
309309
Text the user-entered to filter down on the list of options. Binds both ways, i.e. can also be used to set the input text.
310310

311311
1. ```ts
312-
selected: Option[] | Option | null =
312+
selected: Option[] =
313313
options
314314
?.filter((op) => (op as ObjectOption)?.preselected)
315315
.slice(0, maxSelect ?? undefined) ?? []
316316
```
317317

318-
Array of currently selected options. Supports 2-way binding `bind:selected={[1, 2, 3]}` to control component state externally or passed as prop to set pre-selected options that will already be populated when component mounts before any user interaction. If `maxSelect={1}`, selected will not be an array but a single `Option` or `null` if no options are selected.
318+
Array of currently selected options. Supports 2-way binding `bind:selected={[1, 2, 3]}` to control component state externally. Can be passed as prop to set pre-selected options that will already be populated when component mounts before any user interaction.
319319

320320
1. ```ts
321321
sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
322322
```
323323

324324
Default behavior is to render selected items in the order they were chosen. `sortSelected={true}` uses default JS array sorting. A compare function enables custom logic for sorting selected options. See the [`/sort-selected`](https://svelte-multiselect.netlify.app/sort-selected) example.
325325

326+
1. ```ts
327+
value: Option | Option[] | null = null
328+
```
329+
330+
If `maxSelect={1}`, `value` will be the single item in `selected` (or `null` if `selected` is empty). If `maxSelect != 1`, `maxSelect` and `selected` are equal. Warning: `value` supports 1-way binding only, meaning `bind:value` will update `value` when internal component state changes but changing `value` externally will not update internal component state. This is because `value` is already reactive to `selected` and making `selected` reactive to `value` would be cyclic. Suggestions for better solutions that solve both [#86](https://github.com/janosh/svelte-multiselect/issues/86) and [#136](https://github.com/janosh/svelte-multiselect/issues/136) welcome!
331+
326332
## Slots
327333

328334
`MultiSelect.svelte` has 3 named slots:

src/lib/MultiSelect.svelte

Lines changed: 36 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -54,22 +54,22 @@
5454
export let required: boolean = false
5555
export let resetFilterOnAdd: boolean = true
5656
export let searchText: string = ``
57-
export let selected: Option[] | Option | null =
57+
export let selected: Option[] =
5858
options
5959
?.filter((op) => (op as ObjectOption)?.preselected)
6060
.slice(0, maxSelect ?? undefined) ?? []
6161
export let sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
6262
export let ulOptionsClass: string = ``
6363
export let ulSelectedClass: string = ``
64+
export let value: Option | Option[] | null = null
6465
6566
// get the label key from an option object or the option itself if it's a string or number
6667
const get_label = (op: Option) => (op instanceof Object ? op.label : op)
6768
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
7373
7474
let wiggle = false // controls wiggle animation when user tries to exceed maxSelect
7575
@@ -91,9 +91,9 @@
9191
if (maxSelect !== null && maxSelect < 1) {
9292
console.error(`maxSelect must be null or positive integer, got ${maxSelect}`)
9393
}
94-
if (!Array.isArray(_selected)) {
94+
if (!Array.isArray(selected)) {
9595
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}`
9797
)
9898
}
9999
@@ -103,8 +103,7 @@
103103
104104
// options matching the current search text
105105
$: 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
108107
)
109108
// raise if matchingOptions[activeIndex] does not yield a value
110109
if (activeIndex !== null && !matchingOptions[activeIndex]) {
@@ -115,13 +114,13 @@
115114
116115
// add an option to selected list
117116
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`)
120119
label = Number(label) // convert to number if possible
121120
122-
const is_duplicate = _selected.some((option) => duplicateFunc(option, label))
121+
const is_duplicate = selected.some((option) => duplicateFunc(option, label))
123122
if (
124-
(maxSelect === null || maxSelect === 1 || _selected.length < maxSelect) &&
123+
(maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
125124
(duplicates || !is_duplicate)
126125
) {
127126
// first check if we find option in the options list
@@ -161,20 +160,20 @@
161160
}
162161
if (maxSelect === 1) {
163162
// for maxselect = 1 we always replace current option with new one
164-
_selected = [option]
163+
selected = [option]
165164
} else {
166-
_selected = [..._selected, option]
165+
selected = [...selected, option]
167166
if (sortSelected === true) {
168-
_selected = _selected.sort((op1: Option, op2: Option) => {
167+
selected = selected.sort((op1: Option, op2: Option) => {
169168
const [label1, label2] = [get_label(op1), get_label(op2)]
170169
// coerce to string if labels are numbers
171170
return `${label1}`.localeCompare(`${label2}`)
172171
})
173172
} else if (typeof sortSelected === `function`) {
174-
_selected = _selected.sort(sortSelected)
173+
selected = selected.sort(sortSelected)
175174
}
176175
}
177-
if (_selected.length === maxSelect) close_dropdown(event)
176+
if (selected.length === maxSelect) close_dropdown(event)
178177
else if (
179178
focusInputOnSelect === true ||
180179
(focusInputOnSelect === `desktop` && window_width > breakpoint)
@@ -190,10 +189,10 @@
190189
191190
// remove an option from selected list
192191
function remove(label: string | number) {
193-
if (_selected.length === 0) return
192+
if (selected.length === 0) return
194193
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
197196
198197
const option =
199198
options.find((option) => get_label(option) === label) ??
@@ -241,7 +240,7 @@
241240
242241
if (activeOption) {
243242
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)
245244
searchText = ``
246245
} else if (allowUserOptions && searchText.length > 0) {
247246
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
@@ -285,19 +284,19 @@
285284
}
286285
}
287286
// 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)
290289
}
291290
}
292291
293292
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 = []
297296
searchText = ``
298297
}
299298
300-
$: is_selected = (label: string | number) => _selected.map(get_label).includes(label)
299+
$: is_selected = (label: string | number) => selected.map(get_label).includes(label)
301300
302301
const if_enter_or_space = (handler: () => void) => (event: KeyboardEvent) => {
303302
if ([`Enter`, `Space`].includes(event.code)) {
@@ -332,10 +331,10 @@
332331
title={disabled ? disabledInputTitle : null}
333332
aria-disabled={disabled ? `true` : null}
334333
>
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 -->
336335
<input
337336
{required}
338-
bind:value={_selected}
337+
bind:value={selected}
339338
tabindex="-1"
340339
aria-hidden="true"
341340
aria-label="ignore this, used only to prevent form submission if select is required but empty"
@@ -344,7 +343,7 @@
344343
/>
345344
<ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt;" />
346345
<ul class="selected {ulSelectedClass}">
347-
{#each _selected as option, idx}
346+
{#each selected as option, idx}
348347
<li class={liSelectedClass} aria-selected="true">
349348
<slot name="selected" {option} {idx}>
350349
{#if parseLabelsAsHtml}
@@ -382,7 +381,7 @@
382381
{disabled}
383382
{inputmode}
384383
{pattern}
385-
placeholder={_selected.length == 0 ? placeholder : null}
384+
placeholder={selected.length == 0 ? placeholder : null}
386385
aria-invalid={invalid ? `true` : null}
387386
on:blur
388387
on:change
@@ -409,15 +408,15 @@
409408
<slot name="disabled-icon">
410409
<DisabledIcon width="15px" />
411410
</slot>
412-
{:else if _selected.length > 0}
411+
{:else if selected.length > 0}
413412
{#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
414413
<Wiggle bind:wiggle angle={20}>
415414
<span style="padding: 0 3pt;">
416-
{maxSelectMsg?.(_selected.length, maxSelect)}
415+
{maxSelectMsg?.(selected.length, maxSelect)}
417416
</span>
418417
</Wiggle>
419418
{/if}
420-
{#if maxSelect !== 1 && _selected.length > 1}
419+
{#if maxSelect !== 1 && selected.length > 1}
421420
<button
422421
type="button"
423422
class="remove-all"
@@ -487,7 +486,7 @@
487486
on:blur={() => (add_option_msg_is_active = false)}
488487
aria-selected="false"
489488
>
490-
{!duplicates && _selected.some((option) => duplicateFunc(option, searchText))
489+
{!duplicates && selected.some((option) => duplicateFunc(option, searchText))
491490
? duplicateOptionMsg
492491
: addOptionMsg}
493492
</li>

tests/unit/Test2WayBind.svelte

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts">
2+
import MultiSelect, { type Option } from '$lib'
3+
import { createEventDispatcher } from 'svelte'
4+
5+
export let selected: Option[] = []
6+
export let options: Option[] = [1, 2, 3]
7+
export let value: Option | Option[] | null = null
8+
export let maxSelect: number | null = null
9+
10+
const dispatch = createEventDispatcher()
11+
12+
$: dispatch(`options-changed`, options)
13+
$: dispatch(`selected-changed`, selected)
14+
$: dispatch(`value-changed`, value)
15+
</script>
16+
17+
<MultiSelect bind:options bind:selected bind:value {maxSelect} />

0 commit comments

Comments
 (0)