From 7594ff93f448c6cfb5857a19385b006329e5c130 Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Tue, 23 Jun 2026 20:28:17 -0500 Subject: [PATCH] feat(combobox): add autoHighlight prop --- .changeset/quiet-comboboxes-highlight.md | 5 +++ docs/content/components/combobox.md | 2 ++ .../lib/content/api-reference/combobox.api.ts | 5 +++ .../bits/combobox/components/combobox.svelte | 2 ++ .../bits-ui/src/lib/bits/combobox/types.ts | 8 +++++ .../src/lib/bits/select/select.svelte.ts | 21 +++++++++--- tests/src/tests/browser-utils.ts | 15 ++++++++ .../tests/combobox/combobox.browser.test.ts | 34 ++++++++++++++++++- .../context-menu/context-menu.browser.test.ts | 5 ++- tests/src/tests/select/select.browser.test.ts | 9 ++++- 10 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 .changeset/quiet-comboboxes-highlight.md diff --git a/.changeset/quiet-comboboxes-highlight.md b/.changeset/quiet-comboboxes-highlight.md new file mode 100644 index 000000000..e5aa8efb8 --- /dev/null +++ b/.changeset/quiet-comboboxes-highlight.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +feat(Combobox): add `autoHighlight` prop diff --git a/docs/content/components/combobox.md b/docs/content/components/combobox.md index 39ad80dc3..a2c959f45 100644 --- a/docs/content/components/combobox.md +++ b/docs/content/components/combobox.md @@ -349,6 +349,8 @@ To prevent the user from scrolling outside of the `Combobox.Content` component w 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. +Use the `autoHighlight` prop on `Combobox.Root` to automatically highlight the first matching item after the user filters the list. + ### Styling Highlighted Items You can use the `data-highlighted` attribute on the `Combobox.Item` component to style the item differently when it is highlighted. diff --git a/docs/src/lib/content/api-reference/combobox.api.ts b/docs/src/lib/content/api-reference/combobox.api.ts index 5421acee6..6d30b2557 100644 --- a/docs/src/lib/content/api-reference/combobox.api.ts +++ b/docs/src/lib/content/api-reference/combobox.api.ts @@ -120,6 +120,11 @@ export const root = defineComponentApiSchema({ description: "Whether or not the user can deselect the selected item by pressing it in a single select.", }), + autoHighlight: defineBooleanProp({ + default: false, + description: + "Whether or not the first matching item should be highlighted automatically as the user filters the list.", + }), items: defineComponentPropSchema({ definition: ItemsProp, stringDefinition: `{ value: string; label: string; disabled?: boolean}[]`, diff --git a/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte b/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte index 927477c9d..558f93140 100644 --- a/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte +++ b/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte @@ -21,6 +21,7 @@ required = false, items = [], allowDeselect = true, + autoHighlight = false, inputValue = "", children, }: ComboboxRootProps = $props(); @@ -63,6 +64,7 @@ isCombobox: true, items: boxWith(() => items), allowDeselect: boxWith(() => allowDeselect), + autoHighlight: boxWith(() => autoHighlight), inputValue: boxWith( () => inputValue, (v) => (inputValue = v) diff --git a/packages/bits-ui/src/lib/bits/combobox/types.ts b/packages/bits-ui/src/lib/bits/combobox/types.ts index 9972e811a..adece1955 100644 --- a/packages/bits-ui/src/lib/bits/combobox/types.ts +++ b/packages/bits-ui/src/lib/bits/combobox/types.ts @@ -10,6 +10,14 @@ export type ComboboxBaseRootPropsWithoutHTML = Omit< SelectBaseRootPropsWithoutHTML, "autocomplete" > & { + /** + * Whether the first matching item should be highlighted automatically as the + * user filters the list. + * + * @default false + */ + autoHighlight?: boolean; + /** * A read-only value that can be used to programmatically * update the input value. diff --git a/packages/bits-ui/src/lib/bits/select/select.svelte.ts b/packages/bits-ui/src/lib/bits/select/select.svelte.ts index 508f904b4..0021e71ec 100644 --- a/packages/bits-ui/src/lib/bits/select/select.svelte.ts +++ b/packages/bits-ui/src/lib/bits/select/select.svelte.ts @@ -84,6 +84,7 @@ interface SelectBaseRootStateOpts scrollAlignment: "nearest" | "center"; items: { value: string; label: string; disabled?: boolean }[]; allowDeselect: boolean; + autoHighlight: boolean; onOpenChangeComplete: OnChangeFn; }>, WritableBoxedValues<{ @@ -234,6 +235,10 @@ abstract class SelectBaseRootState { getBitsAttr: typeof selectAttrs.getAttr = (part) => { return selectAttrs.getAttr(part, this.isCombobox ? "combobox" : undefined); }; + + shouldAutoHighlightAfterInput() { + return this.isCombobox && this.opts.autoHighlight.current; + } } interface SelectSingleRootStateOpts @@ -298,6 +303,7 @@ export class SelectSingleRootState extends SelectBaseRootState { setInitialHighlightedNode() { afterTick(() => { + if (this.shouldAutoHighlightAfterInput()) return; if ( this.highlightedNode && this.domContext.getDocument().contains(this.highlightedNode) @@ -362,6 +368,7 @@ class SelectMultipleRootState extends SelectBaseRootState { setInitialHighlightedNode() { afterTick(() => { + if (this.shouldAutoHighlightAfterInput()) return; if (!this.domContext) return; if ( this.highlightedNode && @@ -399,16 +406,18 @@ interface SelectRootStateOpts isCombobox: boolean; type: "single" | "multiple"; value: Box | Box; + autoHighlight?: Box; } export class SelectRootState { static create(props: SelectRootStateOpts): SelectRoot { - const { type, ...rest } = props; + const { type, autoHighlight = boxWith(() => false), ...rest } = props; + const rootProps = { ...rest, autoHighlight }; const rootState = type === "single" - ? new SelectSingleRootState(rest as SelectSingleRootStateOpts) - : new SelectMultipleRootState(rest as SelectMultipleRootStateOpts); + ? new SelectSingleRootState(rootProps as SelectSingleRootStateOpts) + : new SelectMultipleRootState(rootProps as SelectMultipleRootStateOpts); return SelectRootContext.set(rootState); } @@ -636,7 +645,11 @@ export class SelectInputState { oninput(e: BitsEvent) { this.root.opts.inputValue.current = e.currentTarget.value; - this.root.setHighlightedToFirstCandidate(); + if (this.root.shouldAutoHighlightAfterInput()) { + afterTick(() => this.root.setHighlightedToFirstCandidate()); + } else { + this.root.setHighlightedToFirstCandidate(); + } } readonly props = $derived.by( diff --git a/tests/src/tests/browser-utils.ts b/tests/src/tests/browser-utils.ts index 89b5023d1..24f07dd20 100644 --- a/tests/src/tests/browser-utils.ts +++ b/tests/src/tests/browser-utils.ts @@ -86,6 +86,21 @@ export async function expectExists(loc: Locator) { await expect.element(loc).toBeInTheDocument(); } +export async function pointerDown(loc: Locator, init: PointerEventInit = {}) { + loc.element().dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + pointerId: 1, + pointerType: "mouse", + isPrimary: true, + ...init, + }) + ); +} + export async function focusAndExpectToHaveFocus(loc: Locator) { (loc.element() as HTMLElement).focus(); await expect.element(loc).toHaveFocus(); diff --git a/tests/src/tests/combobox/combobox.browser.test.ts b/tests/src/tests/combobox/combobox.browser.test.ts index 258b4d25e..883a8a13f 100644 --- a/tests/src/tests/combobox/combobox.browser.test.ts +++ b/tests/src/tests/combobox/combobox.browser.test.ts @@ -10,7 +10,7 @@ import ComboboxMultiTest from "./combobox-multi-test.svelte"; import ComboboxForceMountTest, { type ComboboxForceMountTestProps, } from "./combobox-force-mount-test.svelte"; -import { expectExists, expectNotExists } from "../browser-utils"; +import { expectExists, expectNotExists, pointerDown } from "../browser-utils"; const kbd = getTestKbd(); @@ -219,6 +219,7 @@ describe("combobox - single", () => { it("should close on outside click", async () => { const t = await openSingle(); + await pointerDown(t.outside); await t.outside.click({ force: true }); await expectNotExists(t.getContent()); }); @@ -306,6 +307,36 @@ describe("combobox - single", () => { await expectHighlighted(item0); }); + it("should auto-highlight the first matching item after input when `autoHighlight` is true", async () => { + const t = setupSingle({ autoHighlight: true }, [ + { value: "1", label: "apple" }, + { value: "2", label: "banana" }, + { value: "3", label: "cherry" }, + { value: "4", label: "date" }, + ]); + await t.trigger.click({ force: true }); + await expectExists(t.getContent()); + const [item1, item2, item3, item4] = getItems(page.getByTestId); + + await expectNotHighlighted([item1, item2, item3, item4]); + await t.user.type(t.input, "b"); + await expectHighlighted(item2); + await expect.element(t.input).toHaveAttribute("aria-activedescendant", item2.element().id); + await t.user.keyboard(kbd.ESCAPE); + await expectNotExists(t.getContent()); + t.unmount(); + }); + + it("should clear the auto-highlight when input filtering removes all items", async () => { + const t = await openSingle({ autoHighlight: true }); + + await t.user.type(t.input, "Z"); + await expect.element(t.input).not.toHaveAttribute("aria-activedescendant"); + await t.user.keyboard(kbd.ESCAPE); + await expectNotExists(t.getContent()); + t.unmount(); + }); + it("should navigate through the items using the keyboard (loop = true)", async () => { await openSingle( { @@ -546,6 +577,7 @@ describe("combobox - multiple", () => { it("should close on outside click", async () => { const t = await openMultiple(); + await pointerDown(t.outside); await t.outside.click({ force: true }); await expectNotExists(t.getContent()); }); diff --git a/tests/src/tests/context-menu/context-menu.browser.test.ts b/tests/src/tests/context-menu/context-menu.browser.test.ts index f331e6ecf..c64789b99 100644 --- a/tests/src/tests/context-menu/context-menu.browser.test.ts +++ b/tests/src/tests/context-menu/context-menu.browser.test.ts @@ -12,6 +12,7 @@ import { getPointerAwayFromSubmenuIntentClientCoords, getPointerLeaveTowardSubmenuClientCoords, getPointerMidpointTowardSubmenuClientCoords, + pointerDown, } from "../browser-utils"; import ContextMenuIntegrationTest from "./context-menu-integration-test.svelte"; import ContextMenuNestedTest from "./context-menu-nested-test.svelte"; @@ -626,7 +627,9 @@ it("should open when right clicked inside a tooltip trigger", async () => { it("should close when the trigger is left clicked and the menu is open", async () => { await open(); - await page.getByTestId("trigger").click({ force: true }); + const trigger = page.getByTestId("trigger"); + await pointerDown(trigger); + await trigger.click({ force: true }); await expectNotExists(page.getByTestId("content")); }); diff --git a/tests/src/tests/select/select.browser.test.ts b/tests/src/tests/select/select.browser.test.ts index 13883703e..41ba12adb 100644 --- a/tests/src/tests/select/select.browser.test.ts +++ b/tests/src/tests/select/select.browser.test.ts @@ -14,7 +14,12 @@ import SelectValueChildTest from "./select-value-child-test.svelte"; import type { SelectValueChildrenMultiTestProps } from "./select-value-children-multi-test.svelte"; import SelectValueChildrenMultiTest from "./select-value-children-multi-test.svelte"; import SelectViewportTest from "./select-viewport-test.svelte"; -import { expectExists, expectNotExists, observeTransitionAttrs } from "../browser-utils"; +import { + expectExists, + expectNotExists, + observeTransitionAttrs, + pointerDown, +} from "../browser-utils"; import SelectScrollJumpTest from "./select-scroll-jump-test.svelte"; import { page, userEvent } from "@vitest/browser/context"; @@ -320,6 +325,7 @@ describe("select - single", () => { it("should close on outside click", async () => { const t = await openSingle(); + await pointerDown(t.outside); await t.outside.click({ force: true }); await expectNotExists(t.getContent()); }); @@ -783,6 +789,7 @@ describe("select - multiple", () => { it("should close on outside click", async () => { const t = await openMultiple(); + await pointerDown(t.outside); await t.outside.click({ force: true }); await expectNotExists(t.getContent()); });