Skip to content

Commit 5ebd21c

Browse files
authored
Open dropdown on programmatic focus and close it on blur (#314)
* open dropdown on programmatic focus and close it on blur (closes #289) new playwright test to verify that input.focus() opens the dropdown and that input.blur() closes it * fix playwright test 'dropdowns in modal render in body when portal is active' * fix TS error in CmdPalette.svelte
1 parent 392189d commit 5ebd21c

File tree

4 files changed

+66
-9
lines changed

4 files changed

+66
-9
lines changed

readme.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ Favorite Frontend Tools?
6868

6969
## 🧠   Mental Model & Core Concepts
7070

71-
### Essential Props
72-
7371
| Prop | Purpose | Value |
7472
| --------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------- |
7573
| `options` | What users can choose from | Array of strings, numbers, or objects with `label` property |

src/lib/CmdPalette.svelte

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import { fade } from 'svelte/transition'
33
import { MultiSelect } from '.'
4-
import type { MultiSelectProps, ObjectOption } from './types'
4+
import type { MultiSelectProps, ObjectOption, Option } from './types'
55
66
interface Action extends ObjectOption {
77
label: string
@@ -51,8 +51,9 @@
5151
}
5252
}
5353
54-
function trigger_action_and_close({ option }: { option: Action }) {
55-
option.action(option.label)
54+
function trigger_action_and_close(data: { option: Option }) {
55+
const { action, label } = data.option as Action
56+
action(label)
5657
open = false
5758
}
5859
</script>

src/lib/MultiSelect.svelte

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,11 +477,41 @@
477477
}
478478
479479
const handle_input_focus: FocusEventHandler<HTMLInputElement> = (event) => {
480-
open_dropdown(event) // Internal logic
481-
// Call original forwarded handler
480+
open_dropdown(event)
482481
onfocus?.(event)
483482
}
484483
484+
// Override input's focus method to ensure dropdown opens on programmatic focus
485+
// https://github.com/janosh/svelte-multiselect/issues/289
486+
$effect(() => {
487+
if (!input) return
488+
489+
const orig_focus = input.focus.bind(input)
490+
491+
input.focus = (options?: FocusOptions) => {
492+
orig_focus(options)
493+
if (!disabled && !open) open_dropdown(new FocusEvent(`focus`, { bubbles: true }))
494+
}
495+
496+
return () => {
497+
if (input) input.focus = orig_focus
498+
}
499+
})
500+
501+
const handle_input_blur: FocusEventHandler<HTMLInputElement> = (event) => {
502+
// For portalled dropdowns, don't close on blur since clicks on portalled elements
503+
// will cause blur but we want to allow the click to register first
504+
if (portal_params?.active) {
505+
onblur?.(event) // Let the click handler manage closing for portalled dropdowns
506+
return
507+
}
508+
509+
// For non-portalled dropdowns, close when focus moves outside the component
510+
if (!outerDiv?.contains(event.relatedTarget as Node)) close_dropdown(event)
511+
512+
onblur?.(event) // Call original handler (if any passed as component prop)
513+
}
514+
485515
// reset form validation when required prop changes
486516
// https://github.com/janosh/svelte-multiselect/issues/285
487517
$effect.pre(() => {
@@ -657,7 +687,7 @@
657687
onkeydown={handle_input_keydown}
658688
onfocus={handle_input_focus}
659689
oninput={highlight_matching_options}
660-
{onblur}
690+
onblur={handle_input_blur}
661691
{onclick}
662692
{onkeyup}
663693
{onmousedown}

tests/MultiSelect.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,34 @@ test.describe(`input`, () => {
2323
await expect(options).toBeVisible()
2424
})
2525

26+
// https://github.com/janosh/svelte-multiselect/issues/289
27+
test(`programmatic focus opens dropdown`, async ({ page }) => {
28+
await page.goto(`/ui`, { waitUntil: `networkidle` })
29+
const dropdown = page.locator(`#foods div.multiselect > ul.options`)
30+
// confirm initial state
31+
await expect(dropdown).toHaveClass(/hidden/)
32+
await expect(dropdown).toBeHidden()
33+
34+
await page.evaluate(() => {
35+
const input = document.querySelector(
36+
`#foods input[autocomplete]`,
37+
) as HTMLInputElement
38+
input?.focus()
39+
})
40+
await expect(dropdown).not.toHaveClass(/hidden/)
41+
await expect(dropdown).toBeVisible()
42+
43+
// also test that input.blur() closes dropdown
44+
await page.evaluate(() => {
45+
const input = document.querySelector(
46+
`#foods input[autocomplete]`,
47+
) as HTMLInputElement
48+
input?.blur()
49+
})
50+
await expect(dropdown).toHaveClass(/hidden/)
51+
await expect(dropdown).toBeHidden()
52+
})
53+
2654
test(`closes dropdown on tab out`, async ({ page }) => {
2755
await page.goto(`/ui`, { waitUntil: `networkidle` })
2856
// note we only test for close on tab out, not on blur since blur should not close in case user
@@ -676,7 +704,7 @@ test.describe(`portal feature`, () => {
676704
await portalled_languages_options
677705
.getByRole(`option`, { name: demo_languages[0], exact: true })
678706
.click()
679-
await expect(portalled_languages_options).toBeVisible() // Reverted: expect to be visible for languages
707+
await expect(portalled_languages_options).toBeVisible()
680708
await expect(
681709
portalled_languages_options.getByRole(`option`, {
682710
name: demo_languages[0],

0 commit comments

Comments
 (0)