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
20 changes: 18 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- **v6.0.0** The prop `showOptions` which controls whether the list of dropdown options is currently open or closed was renamed to `open`. See [PR 103](https://github.com/janosh/svelte-multiselect/pull/103).
- **v6.0.1** The prop `disabledTitle` which sets the title of the `<MultiSelect>` `<input>` node if in `disabled` mode was renamed to `disabledInputTitle`. See [PR 105](https://github.com/janosh/svelte-multiselect/pull/105).
- **v6.0.1** The default margin of `1em 0` on the wrapper `div.multiselect` was removed. Instead, there is now a new CSS variable `--sms-margin`. Set it to `--sms-margin: 1em 0;` to restore the old appearance. See [PR 105](https://github.com/janosh/svelte-multiselect/pull/105).
- **6.1.0** The `dispatch` events `focus` and `blur` were renamed to `open` and `close`, respectively. These actions refer to the dropdown list, i.e. `<MultiSelect on:open={(event) => console.log(event)}>` will trigger when the dropdown list opens. The focus and blur events are now regular DOM (not Svelte `dispatch`) events emitted by the `<input>` node. See [PR 120](https://github.com/janosh/svelte-multiselect/pull/120).

## Installation

Expand Down Expand Up @@ -359,10 +360,16 @@ Example:
Triggers when an option is either added or removed, or all options are removed at once. `type` is one of `'add' | 'remove' | 'removeAll'` and payload will be `option: Option` or `options: Option[]`, respectively.

1. ```ts
on:blur={() => console.log('Multiselect input lost focus')}
on:open={(event) => console.log(`Multiselect dropdown was opened by ${event}`)}
```

Triggers when the input field looses focus.
Triggers when the dropdown list of options appears. Event is the DOM's `FocusEvent`,`KeyboardEvent` or `ClickEvent` that initiated this Svelte `dispatch` event.

1. ```ts
on:close={(event) => console.log(`Multiselect dropdown was closed by ${event}`)}
```

Triggers when the dropdown list of options disappears. Event is the DOM's `FocusEvent`, `KeyboardEvent` or `ClickEvent` that initiated this Svelte `dispatch` event.

For example, here's how you might annoy your users with an alert every time one or more options are added or removed:

Expand All @@ -378,6 +385,15 @@ For example, here's how you might annoy your users with an alert every time one

> Note: Depending on the data passed to the component the `options(s)` payload will either be objects or simple strings/numbers.

This component also forwards many DOM events from the `<input>` node: `blur`, `change`, `click`, `keydown`, `keyup`, `mousedown`, `mouseenter`, `mouseleave`, `touchcancel`, `touchend`, `touchmove`, `touchstart`. You can register listeners for these just like for the above [Svelte `dispatch` events](https://svelte.dev/tutorial/component-events):

```svelte
<MultiSelect
options={[1, 2, 3]}
on:keyup={(event) => console.log('key', event.target.value)}
/>
```

## TypeScript

TypeScript users can import the types used for internal type safety:
Expand Down
47 changes: 32 additions & 15 deletions src/lib/MultiSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
$: activeOption = activeIndex !== null ? matchingOptions[activeIndex] : null

// add an option to selected list
function add(label: string | number) {
function add(label: string | number, event: Event) {
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) {
Expand Down Expand Up @@ -150,7 +150,7 @@
selected = selected.sort(sortSelected)
}
}
if (selected.length === maxSelect) close_dropdown()
if (selected.length === maxSelect) close_dropdown(event)
else if (
focusInputOnSelect === true ||
(focusInputOnSelect === `desktop` && window_width > breakpoint)
Expand Down Expand Up @@ -184,25 +184,28 @@
dispatch(`change`, { option, type: `remove` })
}

function open_dropdown() {
function open_dropdown(event: Event) {
if (disabled) return
open = true
input?.focus()
dispatch(`focus`)
if (!(event instanceof FocusEvent)) {
// avoid double-focussing input when event that opened dropdown was already input FocusEvent
input?.focus()
}
dispatch(`open`, { event })
}

function close_dropdown() {
function close_dropdown(event: Event) {
open = false
input?.blur()
activeOption = null
dispatch(`blur`)
dispatch(`close`, { event })
}

// handle all keyboard events this component receives
async function handle_keydown(event: KeyboardEvent) {
// on escape or tab out of input: dismiss options dropdown and reset search text
if (event.key === `Escape` || event.key === `Tab`) {
close_dropdown()
close_dropdown(event)
searchText = ``
}
// on enter key: toggle active option and reset search text
Expand All @@ -211,15 +214,15 @@

if (activeOption) {
const label = get_label(activeOption)
selectedLabels.includes(label) ? remove(label) : add(label)
selectedLabels.includes(label) ? remove(label) : add(label, event)
searchText = ``
} else if (allowUserOptions && searchText.length > 0) {
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
add(searchText)
add(searchText, event)
}
// no active option and no search text means the options dropdown is closed
// in which case enter means open it
else open_dropdown()
else open_dropdown(event)
}
// on up/down arrow keys: update active option
else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
Expand Down Expand Up @@ -279,7 +282,7 @@

function on_click_outside(event: MouseEvent | TouchEvent) {
if (outerDiv && !outerDiv.contains(event.target as Node)) {
close_dropdown()
close_dropdown(event)
}
}
</script>
Expand Down Expand Up @@ -343,6 +346,7 @@
bind:value={searchText}
on:mouseup|self|stopPropagation={open_dropdown}
on:keydown={handle_keydown}
on:focus
on:focus={open_dropdown}
{id}
{name}
Expand All @@ -351,7 +355,20 @@
{pattern}
placeholder={selectedLabels.length ? `` : placeholder}
aria-invalid={invalid ? `true` : null}
on:blur
on:change
on:click
on:keydown
on:keyup
on:mousedown
on:mouseenter
on:mouseleave
on:touchcancel
on:touchend
on:touchmove
on:touchstart
/>
<!-- the above on:* lines forward potentially useful DOM events -->
</li>
</ul>
{#if loading}
Expand Down Expand Up @@ -399,8 +416,8 @@
{@const active = activeIndex === idx}
<li
on:mousedown|stopPropagation
on:mouseup|stopPropagation={() => {
if (!disabled) is_selected(label) ? remove(label) : add(label)
on:mouseup|stopPropagation={(event) => {
if (!disabled) is_selected(label) ? remove(label) : add(label, event)
}}
title={disabled
? disabledTitle
Expand Down Expand Up @@ -431,7 +448,7 @@
{#if allowUserOptions && searchText}
<li
on:mousedown|stopPropagation
on:mouseup|stopPropagation={() => add(searchText)}
on:mouseup|stopPropagation={(event) => add(searchText, event)}
title={addOptionMsg}
class:active={add_option_msg_is_active}
on:mouseover={() => (add_option_msg_is_active = true)}
Expand Down
16 changes: 14 additions & 2 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,24 @@ export type DispatchEvents = {
options?: Option[]
type: 'add' | 'remove' | 'removeAll'
}
focus: undefined
blur: undefined
open: { event: Event }
close: { event: Event }
}

export type MultiSelectEvents = {
[key in keyof DispatchEvents]: CustomEvent<DispatchEvents[key]>
} & {
blur: FocusEvent
click: MouseEvent
focus: FocusEvent
keydown: KeyboardEvent
keyup: KeyboardEvent
mouseenter: MouseEvent
mouseleave: MouseEvent
touchcancel: TouchEvent
touchend: TouchEvent
touchmove: TouchEvent
touchstart: TouchEvent
}

// get the label key from an option object or the option itself if it's a string or number
Expand Down
39 changes: 37 additions & 2 deletions tests/unit/multiselect.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import MultiSelect from '$lib'
import { beforeEach, describe, expect, test } from 'vitest'
import MultiSelect, { type MultiSelectEvents } from '$lib'
import { beforeEach, describe, expect, test, vi } from 'vitest'

beforeEach(() => {
document.body.innerHTML = ``
Expand Down Expand Up @@ -178,4 +178,39 @@ describe(`MultiSelect`, () => {

expect(selected?.textContent?.trim()).toBe(`1 3`)
})

// https://github.com/janosh/svelte-multiselect/issues/119
test(`invokes callback function on keyup and keydown`, async () => {
const options = [1, 2, 3]

const events: [keyof MultiSelectEvents, Event][] = [
[`blur`, new FocusEvent(`blur`)],
[`click`, new MouseEvent(`click`)],
[`focus`, new FocusEvent(`focus`)],
[`keydown`, new KeyboardEvent(`keydown`, { key: `Enter` })],
[`keyup`, new KeyboardEvent(`keyup`, { key: `Enter` })],
[`mouseenter`, new MouseEvent(`mouseenter`)],
[`mouseleave`, new MouseEvent(`mouseleave`)],
[`touchend`, new TouchEvent(`touchend`)],
[`touchmove`, new TouchEvent(`touchmove`)],
[`touchstart`, new TouchEvent(`touchstart`)],
]

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

const input = document.querySelector(`div.multiselect ul.selected input`)
if (!input) throw new Error(`input not found`)

for (const [event_name, event] of events) {
const callback = vi.fn()
instance.$on(event_name, callback)

input.dispatchEvent(event)
expect(callback, `event type '${event_name}'`).toHaveBeenCalledTimes(1)
expect(callback, `event type '${event_name}'`).toHaveBeenCalledWith(event)
}
})
})