Skip to content

Commit 0cb416e

Browse files
authored
Forward input DOM events (#120)
* rename focus/blur dispatch events to open/close pass through DOM events that caused the dispatch forward many input node DOM events to component consumers: blur, change, click, keydown, keyup, mousedown, mouseenter, mouseleave, touchcancel, touchend, touchmove, touchstart * add tests for new DOM event forwarding * document renamed focus/blur->open/close and new DOM events in readme add recent breaking changes note for v6.1.0
1 parent ff9b461 commit 0cb416e

File tree

4 files changed

+101
-21
lines changed

4 files changed

+101
-21
lines changed

readme.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
- **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).
4141
- **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).
4242
- **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).
43+
- **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).
4344

4445
## Installation
4546

@@ -359,10 +360,16 @@ Example:
359360
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.
360361
361362
1. ```ts
362-
on:blur={() => console.log('Multiselect input lost focus')}
363+
on:open={(event) => console.log(`Multiselect dropdown was opened by ${event}`)}
363364
```
364365
365-
Triggers when the input field looses focus.
366+
Triggers when the dropdown list of options appears. Event is the DOM's `FocusEvent`,`KeyboardEvent` or `ClickEvent` that initiated this Svelte `dispatch` event.
367+
368+
1. ```ts
369+
on:close={(event) => console.log(`Multiselect dropdown was closed by ${event}`)}
370+
```
371+
372+
Triggers when the dropdown list of options disappears. Event is the DOM's `FocusEvent`, `KeyboardEvent` or `ClickEvent` that initiated this Svelte `dispatch` event.
366373
367374
For example, here's how you might annoy your users with an alert every time one or more options are added or removed:
368375
@@ -378,6 +385,15 @@ For example, here's how you might annoy your users with an alert every time one
378385
379386
> Note: Depending on the data passed to the component the `options(s)` payload will either be objects or simple strings/numbers.
380387
388+
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):
389+
390+
```svelte
391+
<MultiSelect
392+
options={[1, 2, 3]}
393+
on:keyup={(event) => console.log('key', event.target.value)}
394+
/>
395+
```
396+
381397
## TypeScript
382398
383399
TypeScript users can import the types used for internal type safety:

src/lib/MultiSelect.svelte

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
$: activeOption = activeIndex !== null ? matchingOptions[activeIndex] : null
102102
103103
// add an option to selected list
104-
function add(label: string | number) {
104+
function add(label: string | number, event: Event) {
105105
if (maxSelect && maxSelect > 1 && selected.length >= maxSelect) wiggle = true
106106
// to prevent duplicate selection, we could add `&& !selectedLabels.includes(label)`
107107
if (maxSelect === null || maxSelect === 1 || selected.length < maxSelect) {
@@ -150,7 +150,7 @@
150150
selected = selected.sort(sortSelected)
151151
}
152152
}
153-
if (selected.length === maxSelect) close_dropdown()
153+
if (selected.length === maxSelect) close_dropdown(event)
154154
else if (
155155
focusInputOnSelect === true ||
156156
(focusInputOnSelect === `desktop` && window_width > breakpoint)
@@ -184,25 +184,28 @@
184184
dispatch(`change`, { option, type: `remove` })
185185
}
186186
187-
function open_dropdown() {
187+
function open_dropdown(event: Event) {
188188
if (disabled) return
189189
open = true
190-
input?.focus()
191-
dispatch(`focus`)
190+
if (!(event instanceof FocusEvent)) {
191+
// avoid double-focussing input when event that opened dropdown was already input FocusEvent
192+
input?.focus()
193+
}
194+
dispatch(`open`, { event })
192195
}
193196
194-
function close_dropdown() {
197+
function close_dropdown(event: Event) {
195198
open = false
196199
input?.blur()
197200
activeOption = null
198-
dispatch(`blur`)
201+
dispatch(`close`, { event })
199202
}
200203
201204
// handle all keyboard events this component receives
202205
async function handle_keydown(event: KeyboardEvent) {
203206
// on escape or tab out of input: dismiss options dropdown and reset search text
204207
if (event.key === `Escape` || event.key === `Tab`) {
205-
close_dropdown()
208+
close_dropdown(event)
206209
searchText = ``
207210
}
208211
// on enter key: toggle active option and reset search text
@@ -211,15 +214,15 @@
211214
212215
if (activeOption) {
213216
const label = get_label(activeOption)
214-
selectedLabels.includes(label) ? remove(label) : add(label)
217+
selectedLabels.includes(label) ? remove(label) : add(label, event)
215218
searchText = ``
216219
} else if (allowUserOptions && searchText.length > 0) {
217220
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
218-
add(searchText)
221+
add(searchText, event)
219222
}
220223
// no active option and no search text means the options dropdown is closed
221224
// in which case enter means open it
222-
else open_dropdown()
225+
else open_dropdown(event)
223226
}
224227
// on up/down arrow keys: update active option
225228
else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
@@ -279,7 +282,7 @@
279282
280283
function on_click_outside(event: MouseEvent | TouchEvent) {
281284
if (outerDiv && !outerDiv.contains(event.target as Node)) {
282-
close_dropdown()
285+
close_dropdown(event)
283286
}
284287
}
285288
</script>
@@ -343,6 +346,7 @@
343346
bind:value={searchText}
344347
on:mouseup|self|stopPropagation={open_dropdown}
345348
on:keydown={handle_keydown}
349+
on:focus
346350
on:focus={open_dropdown}
347351
{id}
348352
{name}
@@ -351,7 +355,20 @@
351355
{pattern}
352356
placeholder={selectedLabels.length ? `` : placeholder}
353357
aria-invalid={invalid ? `true` : null}
358+
on:blur
359+
on:change
360+
on:click
361+
on:keydown
362+
on:keyup
363+
on:mousedown
364+
on:mouseenter
365+
on:mouseleave
366+
on:touchcancel
367+
on:touchend
368+
on:touchmove
369+
on:touchstart
354370
/>
371+
<!-- the above on:* lines forward potentially useful DOM events -->
355372
</li>
356373
</ul>
357374
{#if loading}
@@ -399,8 +416,8 @@
399416
{@const active = activeIndex === idx}
400417
<li
401418
on:mousedown|stopPropagation
402-
on:mouseup|stopPropagation={() => {
403-
if (!disabled) is_selected(label) ? remove(label) : add(label)
419+
on:mouseup|stopPropagation={(event) => {
420+
if (!disabled) is_selected(label) ? remove(label) : add(label, event)
404421
}}
405422
title={disabled
406423
? disabledTitle
@@ -431,7 +448,7 @@
431448
{#if allowUserOptions && searchText}
432449
<li
433450
on:mousedown|stopPropagation
434-
on:mouseup|stopPropagation={() => add(searchText)}
451+
on:mouseup|stopPropagation={(event) => add(searchText, event)}
435452
title={addOptionMsg}
436453
class:active={add_option_msg_is_active}
437454
on:mouseover={() => (add_option_msg_is_active = true)}

src/lib/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,24 @@ export type DispatchEvents = {
2222
options?: Option[]
2323
type: 'add' | 'remove' | 'removeAll'
2424
}
25-
focus: undefined
26-
blur: undefined
25+
open: { event: Event }
26+
close: { event: Event }
2727
}
2828

2929
export type MultiSelectEvents = {
3030
[key in keyof DispatchEvents]: CustomEvent<DispatchEvents[key]>
31+
} & {
32+
blur: FocusEvent
33+
click: MouseEvent
34+
focus: FocusEvent
35+
keydown: KeyboardEvent
36+
keyup: KeyboardEvent
37+
mouseenter: MouseEvent
38+
mouseleave: MouseEvent
39+
touchcancel: TouchEvent
40+
touchend: TouchEvent
41+
touchmove: TouchEvent
42+
touchstart: TouchEvent
3143
}
3244

3345
// get the label key from an option object or the option itself if it's a string or number

tests/unit/multiselect.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import MultiSelect from '$lib'
2-
import { beforeEach, describe, expect, test } from 'vitest'
1+
import MultiSelect, { type MultiSelectEvents } from '$lib'
2+
import { beforeEach, describe, expect, test, vi } from 'vitest'
33

44
beforeEach(() => {
55
document.body.innerHTML = ``
@@ -178,4 +178,39 @@ describe(`MultiSelect`, () => {
178178

179179
expect(selected?.textContent?.trim()).toBe(`1 3`)
180180
})
181+
182+
// https://github.com/janosh/svelte-multiselect/issues/119
183+
test(`invokes callback function on keyup and keydown`, async () => {
184+
const options = [1, 2, 3]
185+
186+
const events: [keyof MultiSelectEvents, Event][] = [
187+
[`blur`, new FocusEvent(`blur`)],
188+
[`click`, new MouseEvent(`click`)],
189+
[`focus`, new FocusEvent(`focus`)],
190+
[`keydown`, new KeyboardEvent(`keydown`, { key: `Enter` })],
191+
[`keyup`, new KeyboardEvent(`keyup`, { key: `Enter` })],
192+
[`mouseenter`, new MouseEvent(`mouseenter`)],
193+
[`mouseleave`, new MouseEvent(`mouseleave`)],
194+
[`touchend`, new TouchEvent(`touchend`)],
195+
[`touchmove`, new TouchEvent(`touchmove`)],
196+
[`touchstart`, new TouchEvent(`touchstart`)],
197+
]
198+
199+
const instance = new MultiSelect({
200+
target: document.body,
201+
props: { options },
202+
})
203+
204+
const input = document.querySelector(`div.multiselect ul.selected input`)
205+
if (!input) throw new Error(`input not found`)
206+
207+
for (const [event_name, event] of events) {
208+
const callback = vi.fn()
209+
instance.$on(event_name, callback)
210+
211+
input.dispatchEvent(event)
212+
expect(callback, `event type '${event_name}'`).toHaveBeenCalledTimes(1)
213+
expect(callback, `event type '${event_name}'`).toHaveBeenCalledWith(event)
214+
}
215+
})
181216
})

0 commit comments

Comments
 (0)