Skip to content

Commit f2b5056

Browse files
authored
fix(dropdown): support typeahead when menu is open (#2357)
Fixes #1975
1 parent 9a3b801 commit f2b5056

File tree

2 files changed

+194
-10
lines changed

2 files changed

+194
-10
lines changed

src/Dropdown/Dropdown.svelte

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
/** Obtain a reference to the button HTML element */
107107
export let ref = null;
108108
109-
import { createEventDispatcher } from "svelte";
109+
import { createEventDispatcher, onMount } from "svelte";
110110
import WarningAltFilled from "../icons/WarningAltFilled.svelte";
111111
import WarningFilled from "../icons/WarningFilled.svelte";
112112
import {
@@ -119,11 +119,28 @@
119119
const dispatch = createEventDispatcher();
120120
121121
let highlightedIndex = -1;
122+
let typeaheadBuffer = "";
123+
let typeaheadTimeout = null;
124+
125+
const TYPEAHEAD_DELAY = 500;
126+
127+
onMount(() => {
128+
return () => {
129+
if (typeaheadTimeout) {
130+
clearTimeout(typeaheadTimeout);
131+
}
132+
};
133+
});
122134
123135
$: inline = type === "inline";
124136
$: selectedItem = items.find((item) => item.id === selectedId);
125137
$: if (!open) {
126138
highlightedIndex = -1;
139+
typeaheadBuffer = "";
140+
if (typeaheadTimeout) {
141+
clearTimeout(typeaheadTimeout);
142+
typeaheadTimeout = null;
143+
}
127144
}
128145
129146
function change(dir) {
@@ -153,6 +170,41 @@
153170
highlightedIndex = index;
154171
}
155172
173+
function typeaheadSearch(char) {
174+
if (items.length === 0) return;
175+
176+
if (typeaheadTimeout) {
177+
clearTimeout(typeaheadTimeout);
178+
}
179+
180+
typeaheadBuffer += char.toLowerCase();
181+
182+
typeaheadTimeout = setTimeout(() => {
183+
typeaheadBuffer = "";
184+
typeaheadTimeout = null;
185+
}, TYPEAHEAD_DELAY);
186+
187+
// Start search from the next index after current highlight, or from 0 if none highlighted.
188+
const startIndex = highlightedIndex >= 0 ? highlightedIndex + 1 : 0;
189+
190+
for (let i = startIndex; i < items.length; i++) {
191+
const itemText = itemToString(items[i]).toLowerCase();
192+
if (itemText.startsWith(typeaheadBuffer) && !items[i].disabled) {
193+
highlightedIndex = i;
194+
return;
195+
}
196+
}
197+
198+
// Wrap around: search from beginning to startIndex.
199+
for (let i = 0; i < startIndex; i++) {
200+
const itemText = itemToString(items[i]).toLowerCase();
201+
if (itemText.startsWith(typeaheadBuffer) && !items[i].disabled) {
202+
highlightedIndex = i;
203+
return;
204+
}
205+
}
206+
}
207+
156208
const dispatchSelect = () => {
157209
dispatch("select", {
158210
selectedId,
@@ -230,11 +282,11 @@
230282
tabindex="0"
231283
aria-expanded={open}
232284
on:keydown={(e) => {
233-
const { key } = e;
234-
if (["Enter", "ArrowDown", "ArrowUp"].includes(key)) {
285+
if (["Enter", "ArrowDown", "ArrowUp"].includes(e.key)) {
235286
e.preventDefault();
236287
}
237-
if (key === "Enter") {
288+
289+
if (e.key === "Enter") {
238290
open = !open;
239291
if (
240292
highlightedIndex > -1 &&
@@ -244,21 +296,29 @@
244296
dispatchSelect();
245297
open = false;
246298
}
247-
} else if (key === "Tab") {
299+
} else if (e.key === "Tab") {
248300
open = false;
249-
} else if (key === "ArrowDown") {
301+
} else if (e.key === "ArrowDown") {
250302
if (!open) open = true;
251303
change(1);
252-
} else if (key === "ArrowUp") {
304+
} else if (e.key === "ArrowUp") {
253305
if (!open) open = true;
254306
change(-1);
255-
} else if (key === "Escape") {
307+
} else if (e.key === "Escape") {
256308
open = false;
309+
} else if (
310+
open &&
311+
e.key.length === 1 &&
312+
!e.ctrlKey &&
313+
!e.metaKey &&
314+
!e.altKey
315+
) {
316+
e.preventDefault();
317+
typeaheadSearch(e.key);
257318
}
258319
}}
259320
on:keyup={(e) => {
260-
const { key } = e;
261-
if ([" "].includes(key)) {
321+
if ([" "].includes(e.key)) {
262322
e.preventDefault();
263323
} else {
264324
return;

tests/Dropdown/Dropdown.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,4 +465,128 @@ describe("Dropdown", () => {
465465
});
466466
});
467467
});
468+
469+
it("should support typeahead when typing with menu open", async () => {
470+
render(Dropdown, {
471+
props: {
472+
items: [
473+
{ id: "0", text: "Apple" },
474+
{ id: "1", text: "Banana" },
475+
{ id: "2", text: "Cherry" },
476+
{ id: "3", text: "Date" },
477+
],
478+
selectedId: "0",
479+
},
480+
});
481+
482+
const button = screen.getByRole("button");
483+
await user.click(button);
484+
485+
// Type 'b' to find Banana
486+
await user.keyboard("b");
487+
488+
// Banana should be highlighted (not selected)
489+
const bananaOption = screen.getByRole("option", { name: "Banana" });
490+
expect(bananaOption).toHaveClass("bx--list-box__menu-item--highlighted");
491+
492+
// Selected item should still be Apple
493+
expect(button).toHaveTextContent("Apple");
494+
495+
// Press Enter to select Banana
496+
await user.keyboard("{Enter}");
497+
expect(button).toHaveTextContent("Banana");
498+
});
499+
500+
it("should support typeahead with multiple characters", async () => {
501+
render(Dropdown, {
502+
props: {
503+
items: [
504+
{ id: "0", text: "Apple" },
505+
{ id: "1", text: "Apricot" },
506+
{ id: "2", text: "Banana" },
507+
],
508+
selectedId: "0",
509+
},
510+
});
511+
512+
const button = screen.getByRole("button");
513+
await user.click(button);
514+
515+
// Type 'apr' to find Apricot
516+
await user.keyboard("apr");
517+
518+
const apricotOption = screen.getByRole("option", { name: "Apricot" });
519+
expect(apricotOption).toHaveClass("bx--list-box__menu-item--highlighted");
520+
});
521+
522+
it("should skip disabled items in typeahead search", async () => {
523+
render(Dropdown, {
524+
props: {
525+
items: [
526+
{ id: "0", text: "Apple" },
527+
{ id: "1", text: "Banana", disabled: true },
528+
{ id: "2", text: "Blueberry" },
529+
],
530+
selectedId: "0",
531+
},
532+
});
533+
534+
const button = screen.getByRole("button");
535+
await user.click(button);
536+
537+
// Type 'b' - should skip Banana and find Blueberry
538+
await user.keyboard("b");
539+
540+
const blueberryText = screen.getByText("Blueberry");
541+
const blueberryOption = blueberryText.closest(".bx--list-box__menu-item");
542+
expect(blueberryOption).toHaveClass("bx--list-box__menu-item--highlighted");
543+
});
544+
545+
it("should be case-insensitive in typeahead search", async () => {
546+
render(Dropdown, {
547+
props: {
548+
items: [
549+
{ id: "0", text: "Apple" },
550+
{ id: "1", text: "Banana" },
551+
],
552+
selectedId: "0",
553+
},
554+
});
555+
556+
const button = screen.getByRole("button");
557+
await user.click(button);
558+
559+
// Type 'B' (uppercase) to find Banana
560+
await user.keyboard("B");
561+
562+
const bananaText = screen.getByText("Banana");
563+
const bananaOption = bananaText.closest(".bx--list-box__menu-item");
564+
expect(bananaOption).toHaveClass("bx--list-box__menu-item--highlighted");
565+
});
566+
567+
it("should wrap around to beginning in typeahead search", async () => {
568+
render(Dropdown, {
569+
props: {
570+
items: [
571+
{ id: "0", text: "Apple" },
572+
{ id: "1", text: "Banana" },
573+
{ id: "2", text: "Cherry" },
574+
],
575+
selectedId: "2",
576+
},
577+
});
578+
579+
const button = screen.getByRole("button");
580+
await user.click(button);
581+
582+
// Navigate down to beyond the last item, which should wrap
583+
await user.keyboard("{ArrowDown}");
584+
585+
// Now type 'b' - should wrap around to find Banana
586+
await user.keyboard("b");
587+
588+
const bananaText = screen.getByText("Banana");
589+
const bananaOption = bananaText.closest(".bx--list-box__menu-item");
590+
expect(bananaOption).toHaveClass("bx--list-box__menu-item--highlighted");
591+
});
468592
});

0 commit comments

Comments
 (0)