Skip to content

Commit cb0f8be

Browse files
authored
Make selected a single value (not a length-1 array) if maxSelect=1 (#123)
* make selected{,Labels,Values} a single value (not a length-1 array) when maxSelect={1} * update readme doc strings for selected{,Labels,Values} * fix initial value of selected on mount ignoring maxSelect selected would be all options with key preselected=true even if those are more than maxSelect * move doc strings for props inputmode and pattern into correct order * add unit tests for maxSelect in tests/unit/multiselect.test.ts
1 parent f0d64fd commit cb0f8be

File tree

4 files changed

+117
-56
lines changed

4 files changed

+117
-56
lines changed

playwright.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { PlaywrightTestConfig } from '@playwright/test'
22

33
const config: PlaywrightTestConfig = {
44
webServer: {
5-
command: `yarn dev --port 3000`,
6-
port: 3000,
5+
command: `yarn dev --port 3005`,
6+
port: 3005,
77
},
88
}
99

readme.md

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ import type { Option } from 'svelte-multiselect'
164164

165165
Handle to the `<input>` DOM node. Only available after component mounts (`null` before then).
166166

167+
1. ```ts
168+
inputmode: string | null = null
169+
```
170+
171+
The `inputmode` attribute hints at the type of data the user may enter. Values like `'numeric' | 'tel' | 'email'` allow browsers to display an appropriate virtual keyboard. See [MDN](https://developer.mozilla.org/docs/Web/HTML/Global_attributes/inputmode) for details.
172+
167173
1. ```ts
168174
invalid: boolean = false
169175
```
@@ -234,6 +240,12 @@ import type { Option } from 'svelte-multiselect'
234240

235241
Whether option labels should be passed to [Svelte's `@html` directive](https://svelte.dev/tutorial/html-tags) or inserted into the DOM as plain text. `true` will raise an error if `allowUserOptions` is also truthy as it makes your site susceptible to [cross-site scripting (XSS) attacks](https://wikipedia.org/wiki/Cross-site_scripting).
236242

243+
1. ```ts
244+
pattern: string | null = null
245+
```
246+
247+
The pattern attribute specifies a regular expression which the input's value must match. If a non-null value doesn't match the `pattern` regex, the read-only `patternMismatch` property will be `true`. See [MDN](https://developer.mozilla.org/docs/Web/HTML/Attributes/pattern) for details.
248+
237249
1. ```ts
238250
placeholder: string | null = null
239251
```
@@ -265,41 +277,32 @@ import type { Option } from 'svelte-multiselect'
265277
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.
266278

267279
1. ```ts
268-
selected: Option[] = options?.filter((op) => op?.preselected) ?? []
280+
selected: Option[] | Option | null =
281+
options
282+
?.filter((op) => (op as ObjectOption)?.preselected)
283+
.slice(0, maxSelect ?? undefined) ?? []
269284
```
270285

271-
Array of currently selected options. Can be bound to `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.
286+
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.
272287

273288
1. ```ts
274-
selectedLabels: (string | number)[] = []
289+
selectedLabels: (string | number)[] | string | number | null = []
275290
```
276291

277-
Labels of currently selected options. Exposed just for convenience, equivalent to `selected.map(op => op.label)` when options are objects. If options are simple strings, `selected === selectedLabels`. Supports binding but is read-only, i.e. since this value is reactive to `selected`, you cannot control `selected` by changing `bind:selectedLabels`.
292+
Labels of currently selected options. Exposed just for convenience, equivalent to `selected.map(op => op.label)` when options are objects. If options are simple strings, `selected === selectedLabels`. Supports binding but is read-only, i.e. since this value is reactive to `selected`, you cannot control `selected` by changing `bind:selectedLabels`. If `maxSelect={1}`, selectedLabels will not be an array but a single `string | number` or `null` if no options are selected.
278293

279294
1. ```ts
280-
selectedValues: unknown[] = []
295+
selectedValues: unknown[] | unknown | null = []
281296
```
282297

283-
Values of currently selected options. Exposed just for convenience, equivalent to `selected.map(op => op.value)` when options are objects. If options are simple strings, `selected === selectedValues`. Supports binding but is read-only, i.e. since this value is reactive to `selected`, you cannot control `selected` by changing `bind:selectedValues`.
298+
Values of currently selected options. Exposed just for convenience, equivalent to `selected.map(op => op.value)` when options are objects. If options are simple strings, `selected === selectedValues`. Supports binding but is read-only, i.e. since this value is reactive to `selected`, you cannot control `selected` by changing `bind:selectedValues`. If `maxSelect={1}`, selectedLabels will not be an array but a single value or `null` if no options are selected.
284299

285300
1. ```ts
286301
sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
287302
```
288303

289304
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.
290305

291-
1. ```ts
292-
inputmode: string = ``
293-
```
294-
295-
The `inputmode` attribute hints at the type of data the user may enter. Values like `'numeric' | 'tel' | 'email'` allow browsers to display an appropriate virtual keyboard. See [MDN](https://developer.mozilla.org/docs/Web/HTML/Global_attributes/inputmode) for details.
296-
297-
1. ```ts
298-
pattern: string = ``
299-
```
300-
301-
The pattern attribute specifies a regular expression which the input's value must match. If a non-null value doesn't match the `pattern` regex, the read-only `patternMismatch` property will be `true`. See [MDN](https://developer.mozilla.org/docs/Web/HTML/Attributes/pattern) for details.
302-
303306
## Slots
304307

305308
`MultiSelect.svelte` has 3 named slots:

src/lib/MultiSelect.svelte

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
export let id: string | null = null
2525
export let input: HTMLInputElement | null = null
2626
export let inputClass: string = ``
27+
export let inputmode: string | null = null
2728
export let invalid: boolean = false
2829
export let liActiveOptionClass: string = ``
2930
export let liOptionClass: string = ``
@@ -39,20 +40,33 @@
3940
export let outerDiv: HTMLDivElement | null = null
4041
export let outerDivClass: string = ``
4142
export let parseLabelsAsHtml: boolean = false // should not be combined with allowUserOptions!
43+
export let pattern: string | null = null
4244
export let placeholder: string | null = null
4345
export let removeAllTitle: string = `Remove all`
4446
export let removeBtnTitle: string = `Remove`
4547
export let required: boolean = false
4648
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 = []
5155
export let sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
5256
export let ulOptionsClass: string = ``
5357
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
5670
5771
type $$Events = MultiSelectEvents // for type-safe event listening on this component
5872
@@ -72,26 +86,24 @@
7286
if (maxSelect !== null && maxSelect < 1) {
7387
console.error(`maxSelect must be null or positive integer, got ${maxSelect}`)
7488
}
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+
)
7793
}
7894
7995
const dispatch = createEventDispatcher<DispatchEvents>()
8096
let add_option_msg_is_active: boolean = false // controls active state of <li>{addOptionMsg}</li>
8197
let window_width: number
8298
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-
8799
// formValue binds to input.form-control to prevent form submission if required
88100
// prop is true and no options are selected
89-
$: formValue = selectedValues.join(`,`)
101+
$: formValue = _selectedValues.join(`,`)
90102
$: if (formValue) invalid = false // reset error status whenever component state changes
91103
92104
// options matching the current search text
93105
$: 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
95107
)
96108
// raise if matchingOptions[activeIndex] does not yield a value
97109
if (activeIndex !== null && !matchingOptions[activeIndex]) {
@@ -102,9 +114,9 @@
102114
103115
// add an option to selected list
104116
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
106118
// 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) {
108120
// first check if we find option in the options list
109121
110122
let option = options.find((op) => get_label(op) === label)
@@ -137,20 +149,20 @@
137149
}
138150
if (maxSelect === 1) {
139151
// for maxselect = 1 we always replace current option with new one
140-
selected = [option]
152+
_selected = [option]
141153
} else {
142-
selected = [...selected, option]
154+
_selected = [..._selected, option]
143155
if (sortSelected === true) {
144-
selected = selected.sort((op1: Option, op2: Option) => {
156+
_selected = _selected.sort((op1: Option, op2: Option) => {
145157
const [label1, label2] = [get_label(op1), get_label(op2)]
146158
// coerce to string if labels are numbers
147159
return `${label1}`.localeCompare(`${label2}`)
148160
})
149161
} else if (typeof sortSelected === `function`) {
150-
selected = selected.sort(sortSelected)
162+
_selected = _selected.sort(sortSelected)
151163
}
152164
}
153-
if (selected.length === maxSelect) close_dropdown(event)
165+
if (_selected.length === maxSelect) close_dropdown(event)
154166
else if (
155167
focusInputOnSelect === true ||
156168
(focusInputOnSelect === `desktop` && window_width > breakpoint)
@@ -164,10 +176,10 @@
164176
165177
// remove an option from selected list
166178
function remove(label: string | number) {
167-
if (selected.length === 0) return
179+
if (_selected.length === 0) return
168180
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
171183
172184
const option =
173185
options.find((option) => get_label(option) === label) ??
@@ -259,19 +271,19 @@
259271
}
260272
}
261273
// 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)
264276
}
265277
}
266278
267279
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 = []
271283
searchText = ``
272284
}
273285
274-
$: is_selected = (label: string | number) => selectedLabels.includes(label)
286+
$: is_selected = (label: string | number) => _selectedLabels.includes(label)
275287
276288
const if_enter_or_space = (handler: () => void) => (event: KeyboardEvent) => {
277289
if ([`Enter`, `Space`].includes(event.code)) {
@@ -317,7 +329,7 @@
317329
/>
318330
<ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt;" />
319331
<ul class="selected {ulSelectedClass}">
320-
{#each selected as option, idx}
332+
{#each _selected as option, idx}
321333
<li class={liSelectedClass} aria-selected="true">
322334
<slot name="selected" {option} {idx}>
323335
{#if parseLabelsAsHtml}
@@ -353,7 +365,7 @@
353365
{disabled}
354366
{inputmode}
355367
{pattern}
356-
placeholder={selectedLabels.length ? `` : placeholder}
368+
placeholder={_selected.length == 0 ? placeholder : null}
357369
aria-invalid={invalid ? `true` : null}
358370
on:blur
359371
on:change
@@ -380,16 +392,16 @@
380392
<slot name="disabled-icon">
381393
<DisabledIcon width="15px" />
382394
</slot>
383-
{:else if selected.length > 0}
395+
{:else if _selected.length > 0}
384396
{#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
385397
<Wiggle bind:wiggle angle={20}>
386398
<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}` : ``)}
389401
</span>
390402
</Wiggle>
391403
{/if}
392-
{#if maxSelect !== 1 && selected.length > 1}
404+
{#if maxSelect !== 1 && _selected.length > 1}
393405
<button
394406
type="button"
395407
class="remove-all"

tests/unit/multiselect.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,50 @@ describe(`MultiSelect`, () => {
213213
expect(callback, `event type '${event_name}'`).toHaveBeenCalledWith(event)
214214
}
215215
})
216+
217+
test(`selected is a single option (not length-1 array) when maxSelect=1`, async () => {
218+
const options = [1, 2, 3].map((itm) => ({
219+
label: itm,
220+
value: itm,
221+
preselected: true,
222+
}))
223+
224+
const instance = new MultiSelect({
225+
target: document.body,
226+
props: { options, maxSelect: 1 },
227+
})
228+
229+
const selected = instance.$$.ctx[instance.$$.props.selected]
230+
231+
// this also tests that only 1st option is preselected although all options are marked such
232+
expect(selected).toBe(options[0])
233+
})
234+
235+
test(`selected is null when maxSelect=1 and no option is preselected`, async () => {
236+
const instance = new MultiSelect({
237+
target: document.body,
238+
props: { options: [1, 2, 3], maxSelect: 1 },
239+
})
240+
241+
const selected = instance.$$.ctx[instance.$$.props.selected]
242+
243+
expect(selected).toBe(null)
244+
})
245+
246+
test(`selected is array of options when maxSelect=2`, async () => {
247+
const options = [1, 2, 3].map((itm) => ({
248+
label: itm,
249+
value: itm,
250+
preselected: true,
251+
}))
252+
253+
const instance = new MultiSelect({
254+
target: document.body,
255+
props: { options, maxSelect: 2 },
256+
})
257+
258+
const selected = instance.$$.ctx[instance.$$.props.selected]
259+
260+
expect(selected).toEqual(options.slice(0, 2))
261+
})
216262
})

0 commit comments

Comments
 (0)