Skip to content

Commit 8d19314

Browse files
committed
feat(combobox): add autoHighlight prop
1 parent 263d098 commit 8d19314

10 files changed

Lines changed: 96 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bits-ui": patch
3+
---
4+
5+
feat(Combobox): add `autoHighlight` prop

docs/content/components/combobox.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ To prevent the user from scrolling outside of the `Combobox.Content` component w
349349

350350
The Combobox component follows the [WAI-ARIA descendant pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox) for highlighting items. This means that the `Combobox.Input` retains focus the entire time, even when navigating with the keyboard, and items are highlighted as the user navigates them.
351351

352+
Use the `autoHighlight` prop on `Combobox.Root` to automatically highlight the first matching item after the user filters the list.
353+
352354
### Styling Highlighted Items
353355

354356
You can use the `data-highlighted` attribute on the `Combobox.Item` component to style the item differently when it is highlighted.

docs/src/lib/content/api-reference/combobox.api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ export const root = defineComponentApiSchema<ComboboxRootPropsWithoutHTML>({
120120
description:
121121
"Whether or not the user can deselect the selected item by pressing it in a single select.",
122122
}),
123+
autoHighlight: defineBooleanProp({
124+
default: false,
125+
description:
126+
"Whether or not the first matching item should be highlighted automatically as the user filters the list.",
127+
}),
123128
items: defineComponentPropSchema({
124129
definition: ItemsProp,
125130
stringDefinition: `{ value: string; label: string; disabled?: boolean}[]`,

packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
required = false,
2222
items = [],
2323
allowDeselect = true,
24+
autoHighlight = false,
2425
inputValue = "",
2526
children,
2627
}: ComboboxRootProps = $props();
@@ -63,6 +64,7 @@
6364
isCombobox: true,
6465
items: boxWith(() => items),
6566
allowDeselect: boxWith(() => allowDeselect),
67+
autoHighlight: boxWith(() => autoHighlight),
6668
inputValue: boxWith(
6769
() => inputValue,
6870
(v) => (inputValue = v)

packages/bits-ui/src/lib/bits/combobox/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ export type ComboboxBaseRootPropsWithoutHTML = Omit<
1010
SelectBaseRootPropsWithoutHTML,
1111
"autocomplete"
1212
> & {
13+
/**
14+
* Whether the first matching item should be highlighted automatically as the
15+
* user filters the list.
16+
*
17+
* @default false
18+
*/
19+
autoHighlight?: boolean;
20+
1321
/**
1422
* A read-only value that can be used to programmatically
1523
* update the input value.

packages/bits-ui/src/lib/bits/select/select.svelte.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ interface SelectBaseRootStateOpts
8484
scrollAlignment: "nearest" | "center";
8585
items: { value: string; label: string; disabled?: boolean }[];
8686
allowDeselect: boolean;
87+
autoHighlight: boolean;
8788
onOpenChangeComplete: OnChangeFn<boolean>;
8889
}>,
8990
WritableBoxedValues<{
@@ -234,6 +235,10 @@ abstract class SelectBaseRootState {
234235
getBitsAttr: typeof selectAttrs.getAttr = (part) => {
235236
return selectAttrs.getAttr(part, this.isCombobox ? "combobox" : undefined);
236237
};
238+
239+
shouldAutoHighlightAfterInput() {
240+
return this.isCombobox && this.opts.autoHighlight.current;
241+
}
237242
}
238243

239244
interface SelectSingleRootStateOpts
@@ -298,6 +303,7 @@ export class SelectSingleRootState extends SelectBaseRootState {
298303

299304
setInitialHighlightedNode() {
300305
afterTick(() => {
306+
if (this.shouldAutoHighlightAfterInput()) return;
301307
if (
302308
this.highlightedNode &&
303309
this.domContext.getDocument().contains(this.highlightedNode)
@@ -362,6 +368,7 @@ class SelectMultipleRootState extends SelectBaseRootState {
362368

363369
setInitialHighlightedNode() {
364370
afterTick(() => {
371+
if (this.shouldAutoHighlightAfterInput()) return;
365372
if (!this.domContext) return;
366373
if (
367374
this.highlightedNode &&
@@ -399,16 +406,18 @@ interface SelectRootStateOpts
399406
isCombobox: boolean;
400407
type: "single" | "multiple";
401408
value: Box<string> | Box<string[]>;
409+
autoHighlight?: Box<boolean>;
402410
}
403411

404412
export class SelectRootState {
405413
static create(props: SelectRootStateOpts): SelectRoot {
406-
const { type, ...rest } = props;
414+
const { type, autoHighlight = boxWith(() => false), ...rest } = props;
415+
const rootProps = { ...rest, autoHighlight };
407416

408417
const rootState =
409418
type === "single"
410-
? new SelectSingleRootState(rest as SelectSingleRootStateOpts)
411-
: new SelectMultipleRootState(rest as SelectMultipleRootStateOpts);
419+
? new SelectSingleRootState(rootProps as SelectSingleRootStateOpts)
420+
: new SelectMultipleRootState(rootProps as SelectMultipleRootStateOpts);
412421

413422
return SelectRootContext.set(rootState);
414423
}
@@ -636,7 +645,11 @@ export class SelectInputState {
636645

637646
oninput(e: BitsEvent<Event, HTMLInputElement>) {
638647
this.root.opts.inputValue.current = e.currentTarget.value;
639-
this.root.setHighlightedToFirstCandidate();
648+
if (this.root.shouldAutoHighlightAfterInput()) {
649+
afterTick(() => this.root.setHighlightedToFirstCandidate());
650+
} else {
651+
this.root.setHighlightedToFirstCandidate();
652+
}
640653
}
641654

642655
readonly props = $derived.by(

tests/src/tests/browser-utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,21 @@ export async function expectExists(loc: Locator) {
8686
await expect.element(loc).toBeInTheDocument();
8787
}
8888

89+
export async function pointerDown(loc: Locator, init: PointerEventInit = {}) {
90+
loc.element().dispatchEvent(
91+
new PointerEvent("pointerdown", {
92+
bubbles: true,
93+
cancelable: true,
94+
button: 0,
95+
buttons: 1,
96+
pointerId: 1,
97+
pointerType: "mouse",
98+
isPrimary: true,
99+
...init,
100+
})
101+
);
102+
}
103+
89104
export async function focusAndExpectToHaveFocus(loc: Locator) {
90105
(loc.element() as HTMLElement).focus();
91106
await expect.element(loc).toHaveFocus();

tests/src/tests/combobox/combobox.browser.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,36 @@ describe("combobox - single", () => {
306306
await expectHighlighted(item0);
307307
});
308308

309+
it("should auto-highlight the first matching item after input when `autoHighlight` is true", async () => {
310+
const t = setupSingle({ autoHighlight: true }, [
311+
{ value: "1", label: "apple" },
312+
{ value: "2", label: "banana" },
313+
{ value: "3", label: "cherry" },
314+
{ value: "4", label: "date" },
315+
]);
316+
await t.trigger.click({ force: true });
317+
await expectExists(t.getContent());
318+
const [item1, item2, item3, item4] = getItems(page.getByTestId);
319+
320+
await expectNotHighlighted([item1, item2, item3, item4]);
321+
await t.user.type(t.input, "b");
322+
await expectHighlighted(item2);
323+
await expect.element(t.input).toHaveAttribute("aria-activedescendant", item2.element().id);
324+
await t.user.keyboard(kbd.ESCAPE);
325+
await expectNotExists(t.getContent());
326+
t.unmount();
327+
});
328+
329+
it("should clear the auto-highlight when input filtering removes all items", async () => {
330+
const t = await openSingle({ autoHighlight: true });
331+
332+
await t.user.type(t.input, "Z");
333+
await expect.element(t.input).not.toHaveAttribute("aria-activedescendant");
334+
await t.user.keyboard(kbd.ESCAPE);
335+
await expectNotExists(t.getContent());
336+
t.unmount();
337+
});
338+
309339
it("should navigate through the items using the keyboard (loop = true)", async () => {
310340
await openSingle(
311341
{

tests/src/tests/context-menu/context-menu.browser.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getPointerAwayFromSubmenuIntentClientCoords,
1313
getPointerLeaveTowardSubmenuClientCoords,
1414
getPointerMidpointTowardSubmenuClientCoords,
15+
pointerDown,
1516
} from "../browser-utils";
1617
import ContextMenuIntegrationTest from "./context-menu-integration-test.svelte";
1718
import ContextMenuNestedTest from "./context-menu-nested-test.svelte";
@@ -626,7 +627,9 @@ it("should open when right clicked inside a tooltip trigger", async () => {
626627

627628
it("should close when the trigger is left clicked and the menu is open", async () => {
628629
await open();
629-
await page.getByTestId("trigger").click({ force: true });
630+
const trigger = page.getByTestId("trigger");
631+
await pointerDown(trigger);
632+
await trigger.click({ force: true });
630633
await expectNotExists(page.getByTestId("content"));
631634
});
632635

tests/src/tests/select/select.browser.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import SelectValueChildTest from "./select-value-child-test.svelte";
1414
import type { SelectValueChildrenMultiTestProps } from "./select-value-children-multi-test.svelte";
1515
import SelectValueChildrenMultiTest from "./select-value-children-multi-test.svelte";
1616
import SelectViewportTest from "./select-viewport-test.svelte";
17-
import { expectExists, expectNotExists, observeTransitionAttrs } from "../browser-utils";
17+
import {
18+
expectExists,
19+
expectNotExists,
20+
observeTransitionAttrs,
21+
pointerDown,
22+
} from "../browser-utils";
1823
import SelectScrollJumpTest from "./select-scroll-jump-test.svelte";
1924
import { page, userEvent } from "@vitest/browser/context";
2025

@@ -320,6 +325,7 @@ describe("select - single", () => {
320325
it("should close on outside click", async () => {
321326
const t = await openSingle();
322327

328+
await pointerDown(t.outside);
323329
await t.outside.click({ force: true });
324330
await expectNotExists(t.getContent());
325331
});
@@ -783,6 +789,7 @@ describe("select - multiple", () => {
783789

784790
it("should close on outside click", async () => {
785791
const t = await openMultiple();
792+
await pointerDown(t.outside);
786793
await t.outside.click({ force: true });
787794
await expectNotExists(t.getContent());
788795
});

0 commit comments

Comments
 (0)