Skip to content

Commit f025db8

Browse files
authored
Add new value 'retain-focus' for prop closeDropdownOnSelect (#318)
* add new value 'retain-focus' for closeDropdownOnSelect (closes #268) * breaking: rename default `closeDropdownOnSelect` from `desktop` to `if-mobile` for clarity * document closeDropdownOnSelect: 'retain-focus' in readme
1 parent 00784f2 commit f025db8

File tree

4 files changed

+90
-12
lines changed

4 files changed

+90
-12
lines changed

readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,10 @@ These are the core props you'll use in most cases:
263263
Function to determine option equality. Default compares by lowercased label.
264264

265265
1. ```ts
266-
closeDropdownOnSelect: boolean | 'desktop' = 'desktop'
266+
closeDropdownOnSelect: boolean | 'if-mobile' | 'retain-focus' = 'if-mobile'
267267
```
268268

269-
Whether to close dropdown after selection. `'desktop'` means close on mobile only.
269+
Whether to close dropdown after selection. `'if-mobile'` closes dropdown on mobile devices only (responsive). `'retain-focus'` closes dropdown but keeps input focused for rapid typing to create custom options from text input (see `allowUserOptions`).
270270

271271
1. ```ts
272272
resetFilterOnAdd: boolean = true

src/lib/MultiSelect.svelte

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
if (!searchText) return true
3030
return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase())
3131
},
32-
closeDropdownOnSelect = `desktop`,
32+
closeDropdownOnSelect = `if-mobile`,
3333
form_input = $bindable(null),
3434
highlightMatches = true,
3535
id = null,
@@ -263,10 +263,13 @@
263263
264264
const dropdown_should_close =
265265
closeDropdownOnSelect === true ||
266-
(closeDropdownOnSelect === `desktop` && window_width && window_width < breakpoint)
266+
closeDropdownOnSelect === `retain-focus` ||
267+
(closeDropdownOnSelect === `if-mobile` && window_width && window_width < breakpoint)
268+
269+
const should_retain_focus = closeDropdownOnSelect === `retain-focus`
267270
268271
if (reached_max_select || dropdown_should_close) {
269-
close_dropdown(event)
272+
close_dropdown(event, should_retain_focus)
270273
} else if (!dropdown_should_close) {
271274
input?.focus()
272275
}
@@ -322,9 +325,9 @@
322325
onopen?.({ event })
323326
}
324327
325-
function close_dropdown(event: Event) {
328+
function close_dropdown(event: Event, retain_focus = false) {
326329
open = false
327-
input?.blur()
330+
if (!retain_focus) input?.blur()
328331
activeIndex = null
329332
onclose?.({ event })
330333
}

src/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export interface MultiSelectParameters<T extends Option = Option> {
113113
// case-insensitive equality comparison after string coercion and looks only at the `label` key of object options by default
114114
key?: (opt: T) => unknown
115115
filterFunc?: (opt: T, searchText: string) => boolean
116-
closeDropdownOnSelect?: boolean | `desktop`
116+
closeDropdownOnSelect?: boolean | `if-mobile` | `retain-focus`
117117
form_input?: HTMLInputElement | null
118118
highlightMatches?: boolean
119119
id?: string | null

tests/unit/MultiSelect.svelte.test.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,7 +1551,7 @@ test.each([
15511551
},
15521552
)
15531553

1554-
test.each([true, false, `desktop`] as const)(
1554+
test.each([true, false, `if-mobile`] as const)(
15551555
`closeDropdownOnSelect=%s controls input focus and dropdown closing`,
15561556
async (closeDropdownOnSelect) => {
15571557
window.innerWidth = 600 // simulate mobile
@@ -1568,7 +1568,7 @@ test.each([true, false, `desktop`] as const)(
15681568
const is_desktop = window.innerWidth > select.breakpoint
15691569
const should_be_closed =
15701570
closeDropdownOnSelect === true ||
1571-
(closeDropdownOnSelect === `desktop` && !is_desktop)
1571+
(closeDropdownOnSelect === `if-mobile` && !is_desktop)
15721572

15731573
// count number of selected items
15741574
const selected_items = document.querySelectorAll(`ul.selected > li`)
@@ -1593,7 +1593,7 @@ test.each([true, false, `desktop`] as const)(
15931593
expect(document.activeElement === input_el).toBe(!should_be_closed)
15941594
}
15951595

1596-
if (closeDropdownOnSelect === `desktop`) {
1596+
if (closeDropdownOnSelect === `if-mobile`) {
15971597
// reduce window width to simulate mobile
15981598
window.innerWidth = 400
15991599
window.dispatchEvent(new Event(`resize`))
@@ -1605,7 +1605,7 @@ test.each([true, false, `desktop`] as const)(
16051605
) as HTMLLIElement
16061606
if (another_option) {
16071607
another_option.click()
1608-
// On mobile (when closeDropdownOnSelect = 'desktop'), dropdown should close, input should lose focus
1608+
// On mobile (when closeDropdownOnSelect = 'if-mobile'), dropdown should close, input should lose focus
16091609
expect(dropdown.classList).toContain(`hidden`) // Now it should be closed
16101610
expect(document.activeElement === input_el).toBe(false)
16111611
} else {
@@ -1616,3 +1616,78 @@ test.each([true, false, `desktop`] as const)(
16161616
}
16171617
},
16181618
)
1619+
1620+
test(`closeDropdownOnSelect='retain-focus' retains input focus when dropdown closes after option selection`, async () => {
1621+
mount(MultiSelect, {
1622+
target: document.body,
1623+
props: {
1624+
options: [1, 2, 3],
1625+
closeDropdownOnSelect: `retain-focus`,
1626+
open: true,
1627+
},
1628+
})
1629+
1630+
const input_el = doc_query<HTMLInputElement>(`input[autocomplete]`)
1631+
input_el.focus()
1632+
await tick()
1633+
1634+
// select an option - should close dropdown but retain focus
1635+
doc_query(`ul.options > li`).click()
1636+
await tick()
1637+
1638+
expect(document.activeElement).toBe(input_el)
1639+
expect(document.querySelectorAll(`ul.selected > li`)).toHaveLength(1)
1640+
})
1641+
1642+
test(`closeDropdownOnSelect='retain-focus' works correctly with maxSelect`, async () => {
1643+
mount(MultiSelect, {
1644+
target: document.body,
1645+
props: {
1646+
options: [1, 2, 3],
1647+
closeDropdownOnSelect: `retain-focus`,
1648+
maxSelect: 2,
1649+
open: true,
1650+
},
1651+
})
1652+
1653+
const input_el = doc_query<HTMLInputElement>(`input[autocomplete]`)
1654+
input_el.focus()
1655+
await tick()
1656+
1657+
// select first option
1658+
doc_query(`ul.options > li`).click()
1659+
await tick()
1660+
expect(document.activeElement).toBe(input_el)
1661+
1662+
// select second option (reaching maxSelect)
1663+
input_el.dispatchEvent(new MouseEvent(`mouseup`, { bubbles: true }))
1664+
await tick()
1665+
doc_query(`ul.options > li`).click()
1666+
await tick()
1667+
1668+
expect(document.activeElement).toBe(input_el)
1669+
expect(document.querySelectorAll(`ul.selected > li`)).toHaveLength(2)
1670+
})
1671+
1672+
test(`Escape and Tab still blur input even with closeDropdownOnSelect='retain-focus'`, async () => {
1673+
mount(MultiSelect, {
1674+
target: document.body,
1675+
props: {
1676+
options: [1, 2, 3],
1677+
closeDropdownOnSelect: `retain-focus`,
1678+
open: true,
1679+
},
1680+
})
1681+
1682+
const input_el = doc_query<HTMLInputElement>(`input[autocomplete]`)
1683+
input_el.focus()
1684+
await tick()
1685+
1686+
// Escape should blur input (retain-focus only applies to selection, not keyboard closing)
1687+
input_el.dispatchEvent(
1688+
new KeyboardEvent(`keydown`, { key: `Escape`, bubbles: true }),
1689+
)
1690+
await tick()
1691+
1692+
expect(document.activeElement).not.toBe(input_el)
1693+
})

0 commit comments

Comments
 (0)