Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { PlaywrightTestConfig } from '@playwright/test'

const config: PlaywrightTestConfig = {
webServer: {
command: `yarn dev --port 3000`,
port: 3000,
command: `yarn dev --port 3005`,
port: 3005,
},
}

Expand Down
39 changes: 21 additions & 18 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ import type { Option } from 'svelte-multiselect'

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

1. ```ts
inputmode: string | null = null
```

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.

1. ```ts
invalid: boolean = false
```
Expand Down Expand Up @@ -234,6 +240,12 @@ import type { Option } from 'svelte-multiselect'

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).

1. ```ts
pattern: string | null = null
```

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.

1. ```ts
placeholder: string | null = null
```
Expand Down Expand Up @@ -265,41 +277,32 @@ import type { Option } from 'svelte-multiselect'
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.

1. ```ts
selected: Option[] = options?.filter((op) => op?.preselected) ?? []
selected: Option[] | Option | null =
options
?.filter((op) => (op as ObjectOption)?.preselected)
.slice(0, maxSelect ?? undefined) ?? []
```

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

1. ```ts
selectedLabels: (string | number)[] = []
selectedLabels: (string | number)[] | string | number | null = []
```

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`.
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.

1. ```ts
selectedValues: unknown[] = []
selectedValues: unknown[] | unknown | null = []
```

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`.
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.

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

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.

1. ```ts
inputmode: string = ``
```

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.

1. ```ts
pattern: string = ``
```

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.

## Slots

`MultiSelect.svelte` has 3 named slots:
Expand Down
84 changes: 48 additions & 36 deletions src/lib/MultiSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
export let id: string | null = null
export let input: HTMLInputElement | null = null
export let inputClass: string = ``
export let inputmode: string | null = null
export let invalid: boolean = false
export let liActiveOptionClass: string = ``
export let liOptionClass: string = ``
Expand All @@ -39,20 +40,33 @@
export let outerDiv: HTMLDivElement | null = null
export let outerDivClass: string = ``
export let parseLabelsAsHtml: boolean = false // should not be combined with allowUserOptions!
export let pattern: string | null = null
export let placeholder: string | null = null
export let removeAllTitle: string = `Remove all`
export let removeBtnTitle: string = `Remove`
export let required: boolean = false
export let searchText: string = ``
export let selected: Option[] =
options?.filter((op) => (op as ObjectOption)?.preselected) ?? []
export let selectedLabels: (string | number)[] = []
export let selectedValues: unknown[] = []
export let selected: Option[] | Option | null =
options
?.filter((op) => (op as ObjectOption)?.preselected)
.slice(0, maxSelect ?? undefined) ?? []
export let selectedLabels: (string | number)[] | string | number | null = []
export let selectedValues: unknown[] | unknown | null = []
export let sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
export let ulOptionsClass: string = ``
export let ulSelectedClass: string = ``
export let inputmode: string = ``
export let pattern: string = ``

// selected and _selected are identical except if maxSelect=1, selected will be the single item (or null)
// in _selected which will always be an array for easier component internals. selected then solves
// https://github.com/janosh/svelte-multiselect/issues/86
let _selected = (selected ?? []) as Option[]
$: selected = maxSelect === 1 ? _selected[0] ?? null : _selected

let wiggle = false // controls wiggle animation when user tries to exceed maxSelect
$: _selectedLabels = _selected?.map(get_label) ?? []
$: selectedLabels = maxSelect === 1 ? _selectedLabels[0] ?? null : _selectedLabels
$: _selectedValues = _selected?.map(get_value) ?? []
$: selectedValues = maxSelect === 1 ? _selectedValues[0] ?? null : _selectedValues

type $$Events = MultiSelectEvents // for type-safe event listening on this component

Expand All @@ -72,26 +86,24 @@
if (maxSelect !== null && maxSelect < 1) {
console.error(`maxSelect must be null or positive integer, got ${maxSelect}`)
}
if (!Array.isArray(selected)) {
console.error(`selected prop must be an array, got ${selected}`)
if (!Array.isArray(_selected)) {
console.error(
`internal variable _selected prop should always be an array, got ${_selected}`
)
}

const dispatch = createEventDispatcher<DispatchEvents>()
let add_option_msg_is_active: boolean = false // controls active state of <li>{addOptionMsg}</li>
let window_width: number

let wiggle = false // controls wiggle animation when user tries to exceed maxSelect
$: selectedLabels = selected?.map(get_label) ?? []
$: selectedValues = selected?.map(get_value) ?? []

// formValue binds to input.form-control to prevent form submission if required
// prop is true and no options are selected
$: formValue = selectedValues.join(`,`)
$: formValue = _selectedValues.join(`,`)
$: if (formValue) invalid = false // reset error status whenever component state changes

// options matching the current search text
$: matchingOptions = options.filter(
(op) => filterFunc(op, searchText) && !selectedLabels.includes(get_label(op)) // remove already selected options from dropdown list
(op) => filterFunc(op, searchText) && !_selectedLabels.includes(get_label(op)) // remove already selected options from dropdown list
)
// raise if matchingOptions[activeIndex] does not yield a value
if (activeIndex !== null && !matchingOptions[activeIndex]) {
Expand All @@ -102,9 +114,9 @@

// add an option to selected list
function add(label: string | number, event: Event) {
if (maxSelect && maxSelect > 1 && selected.length >= maxSelect) wiggle = true
if (maxSelect && maxSelect > 1 && _selected.length >= maxSelect) wiggle = true
// to prevent duplicate selection, we could add `&& !selectedLabels.includes(label)`
if (maxSelect === null || maxSelect === 1 || selected.length < maxSelect) {
if (maxSelect === null || maxSelect === 1 || _selected.length < maxSelect) {
// first check if we find option in the options list

let option = options.find((op) => get_label(op) === label)
Expand Down Expand Up @@ -137,20 +149,20 @@
}
if (maxSelect === 1) {
// for maxselect = 1 we always replace current option with new one
selected = [option]
_selected = [option]
} else {
selected = [...selected, option]
_selected = [..._selected, option]
if (sortSelected === true) {
selected = selected.sort((op1: Option, op2: Option) => {
_selected = _selected.sort((op1: Option, op2: Option) => {
const [label1, label2] = [get_label(op1), get_label(op2)]
// coerce to string if labels are numbers
return `${label1}`.localeCompare(`${label2}`)
})
} else if (typeof sortSelected === `function`) {
selected = selected.sort(sortSelected)
_selected = _selected.sort(sortSelected)
}
}
if (selected.length === maxSelect) close_dropdown(event)
if (_selected.length === maxSelect) close_dropdown(event)
else if (
focusInputOnSelect === true ||
(focusInputOnSelect === `desktop` && window_width > breakpoint)
Expand All @@ -164,10 +176,10 @@

// remove an option from selected list
function remove(label: string | number) {
if (selected.length === 0) return
if (_selected.length === 0) return

selected.splice(selectedLabels.lastIndexOf(label), 1)
selected = selected // Svelte rerender after in-place splice
_selected.splice(_selectedLabels.lastIndexOf(label), 1)
_selected = _selected // Svelte rerender after in-place splice

const option =
options.find((option) => get_label(option) === label) ??
Expand Down Expand Up @@ -259,19 +271,19 @@
}
}
// on backspace key: remove last selected option
else if (event.key === `Backspace` && selectedLabels.length > 0 && !searchText) {
remove(selectedLabels.at(-1) as string | number)
else if (event.key === `Backspace` && _selectedLabels.length > 0 && !searchText) {
remove(_selectedLabels.at(-1) as string | number)
}
}

function remove_all() {
dispatch(`removeAll`, { options: selected })
dispatch(`change`, { options: selected, type: `removeAll` })
selected = []
dispatch(`removeAll`, { options: _selected })
dispatch(`change`, { options: _selected, type: `removeAll` })
_selected = []
searchText = ``
}

$: is_selected = (label: string | number) => selectedLabels.includes(label)
$: is_selected = (label: string | number) => _selectedLabels.includes(label)

const if_enter_or_space = (handler: () => void) => (event: KeyboardEvent) => {
if ([`Enter`, `Space`].includes(event.code)) {
Expand Down Expand Up @@ -317,7 +329,7 @@
/>
<ExpandIcon width="15px" style="min-width: 1em; padding: 0 1pt;" />
<ul class="selected {ulSelectedClass}">
{#each selected as option, idx}
{#each _selected as option, idx}
<li class={liSelectedClass} aria-selected="true">
<slot name="selected" {option} {idx}>
{#if parseLabelsAsHtml}
Expand Down Expand Up @@ -353,7 +365,7 @@
{disabled}
{inputmode}
{pattern}
placeholder={selectedLabels.length ? `` : placeholder}
placeholder={_selected.length == 0 ? placeholder : null}
aria-invalid={invalid ? `true` : null}
on:blur
on:change
Expand All @@ -380,16 +392,16 @@
<slot name="disabled-icon">
<DisabledIcon width="15px" />
</slot>
{:else if selected.length > 0}
{:else if _selected.length > 0}
{#if maxSelect && (maxSelect > 1 || maxSelectMsg)}
<Wiggle bind:wiggle angle={20}>
<span style="padding: 0 3pt;">
{maxSelectMsg?.(selected.length, maxSelect) ??
(maxSelect > 1 ? `${selected.length}/${maxSelect}` : ``)}
{maxSelectMsg?.(_selected.length, maxSelect) ??
(maxSelect > 1 ? `${_selected.length}/${maxSelect}` : ``)}
</span>
</Wiggle>
{/if}
{#if maxSelect !== 1 && selected.length > 1}
{#if maxSelect !== 1 && _selected.length > 1}
<button
type="button"
class="remove-all"
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/multiselect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,50 @@ describe(`MultiSelect`, () => {
expect(callback, `event type '${event_name}'`).toHaveBeenCalledWith(event)
}
})

test(`selected is a single option (not length-1 array) when maxSelect=1`, async () => {
const options = [1, 2, 3].map((itm) => ({
label: itm,
value: itm,
preselected: true,
}))

const instance = new MultiSelect({
target: document.body,
props: { options, maxSelect: 1 },
})

const selected = instance.$$.ctx[instance.$$.props.selected]

// this also tests that only 1st option is preselected although all options are marked such
expect(selected).toBe(options[0])
})

test(`selected is null when maxSelect=1 and no option is preselected`, async () => {
const instance = new MultiSelect({
target: document.body,
props: { options: [1, 2, 3], maxSelect: 1 },
})

const selected = instance.$$.ctx[instance.$$.props.selected]

expect(selected).toBe(null)
})

test(`selected is array of options when maxSelect=2`, async () => {
const options = [1, 2, 3].map((itm) => ({
label: itm,
value: itm,
preselected: true,
}))

const instance = new MultiSelect({
target: document.body,
props: { options, maxSelect: 2 },
})

const selected = instance.$$.ctx[instance.$$.props.selected]

expect(selected).toEqual(options.slice(0, 2))
})
})