Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-comboboxes-highlight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

feat(Combobox): add `autoHighlight` prop
2 changes: 2 additions & 0 deletions docs/content/components/combobox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/src/lib/content/api-reference/combobox.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ export const root = defineComponentApiSchema<ComboboxRootPropsWithoutHTML>({
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}[]`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
required = false,
items = [],
allowDeselect = true,
autoHighlight = false,
inputValue = "",
children,
}: ComboboxRootProps = $props();
Expand Down Expand Up @@ -63,6 +64,7 @@
isCombobox: true,
items: boxWith(() => items),
allowDeselect: boxWith(() => allowDeselect),
autoHighlight: boxWith(() => autoHighlight),
inputValue: boxWith(
() => inputValue,
(v) => (inputValue = v)
Expand Down
8 changes: 8 additions & 0 deletions packages/bits-ui/src/lib/bits/combobox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 17 additions & 4 deletions packages/bits-ui/src/lib/bits/select/select.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ interface SelectBaseRootStateOpts
scrollAlignment: "nearest" | "center";
items: { value: string; label: string; disabled?: boolean }[];
allowDeselect: boolean;
autoHighlight: boolean;
onOpenChangeComplete: OnChangeFn<boolean>;
}>,
WritableBoxedValues<{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -298,6 +303,7 @@ export class SelectSingleRootState extends SelectBaseRootState {

setInitialHighlightedNode() {
afterTick(() => {
if (this.shouldAutoHighlightAfterInput()) return;
if (
this.highlightedNode &&
this.domContext.getDocument().contains(this.highlightedNode)
Expand Down Expand Up @@ -362,6 +368,7 @@ class SelectMultipleRootState extends SelectBaseRootState {

setInitialHighlightedNode() {
afterTick(() => {
if (this.shouldAutoHighlightAfterInput()) return;
if (!this.domContext) return;
if (
this.highlightedNode &&
Expand Down Expand Up @@ -399,16 +406,18 @@ interface SelectRootStateOpts
isCombobox: boolean;
type: "single" | "multiple";
value: Box<string> | Box<string[]>;
autoHighlight?: Box<boolean>;
}

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);
}
Expand Down Expand Up @@ -636,7 +645,11 @@ export class SelectInputState {

oninput(e: BitsEvent<Event, HTMLInputElement>) {
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(
Expand Down
30 changes: 30 additions & 0 deletions tests/src/tests/combobox/combobox.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down