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/combobox/combobox.browser.test.ts b/tests/src/tests/combobox/combobox.browser.test.ts index 6815507c5..cc8efd6ae 100644 --- a/tests/src/tests/combobox/combobox.browser.test.ts +++ b/tests/src/tests/combobox/combobox.browser.test.ts @@ -308,6 +308,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( {