;
+
+type HiddenInputProps = DOMElement;
+type SeparatorProps = DOMElement;
+type IndicatorProps = DOMElement;
+
+export type {
+ Props,
+ ContentProps,
+ InputProps,
+ ItemProps,
+ LabelProps,
+ GroupProps,
+ GroupLabelProps,
+ ArrowProps,
+ HiddenInputProps,
+ SeparatorProps,
+ IndicatorProps,
+};
diff --git a/src/lib/bits/combobox/components/combobox-arrow.svelte b/src/lib/bits/combobox/components/combobox-arrow.svelte
new file mode 100644
index 000000000..5f2ce2f41
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-arrow.svelte
@@ -0,0 +1,27 @@
+
+
+{#if asChild}
+
+{:else}
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox-content.svelte b/src/lib/bits/combobox/components/combobox-content.svelte
new file mode 100644
index 000000000..2a6e8925d
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-content.svelte
@@ -0,0 +1,116 @@
+
+
+
+{#if asChild && $open}
+
+{:else if transition && $open}
+
+
+
+{:else if inTransition && outTransition && $open}
+
+
+
+{:else if inTransition && $open}
+
+
+
+{:else if outTransition && $open}
+
+
+
+{:else if $open}
+
+
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox-group-label.svelte b/src/lib/bits/combobox/components/combobox-group-label.svelte
new file mode 100644
index 000000000..733650a8d
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-group-label.svelte
@@ -0,0 +1,30 @@
+
+
+{#if asChild}
+
+{:else}
+
+
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox-group.svelte b/src/lib/bits/combobox/components/combobox-group.svelte
new file mode 100644
index 000000000..dce01be8e
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-group.svelte
@@ -0,0 +1,23 @@
+
+
+{#if asChild}
+
+{:else}
+
+
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox-hidden-input.svelte b/src/lib/bits/combobox/components/combobox-hidden-input.svelte
new file mode 100644
index 000000000..460c8b7fb
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-hidden-input.svelte
@@ -0,0 +1,30 @@
+
+
+{#if asChild}
+
+{:else}
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox-input.svelte b/src/lib/bits/combobox/components/combobox-input.svelte
new file mode 100644
index 000000000..dba389ac2
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-input.svelte
@@ -0,0 +1,38 @@
+
+
+{#if asChild}
+
+{:else}
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox-item-indicator.svelte b/src/lib/bits/combobox/components/combobox-item-indicator.svelte
new file mode 100644
index 000000000..6752dcb7a
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-item-indicator.svelte
@@ -0,0 +1,22 @@
+
+
+{#if asChild}
+
+{:else}
+
+ {#if $isSelected(value)}
+
+ {/if}
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox-item.svelte b/src/lib/bits/combobox/components/combobox-item.svelte
new file mode 100644
index 000000000..4eef3774c
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-item.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+{#if asChild}
+
+{:else}
+
+
+ {label ? label : value}
+
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox-label.svelte b/src/lib/bits/combobox/components/combobox-label.svelte
new file mode 100644
index 000000000..462426737
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox-label.svelte
@@ -0,0 +1,28 @@
+
+
+{#if asChild}
+
+{:else}
+
+{/if}
diff --git a/src/lib/bits/combobox/components/combobox.svelte b/src/lib/bits/combobox/components/combobox.svelte
new file mode 100644
index 000000000..938bd871d
--- /dev/null
+++ b/src/lib/bits/combobox/components/combobox.svelte
@@ -0,0 +1,116 @@
+
+
+
+
+
diff --git a/src/lib/bits/combobox/ctx.ts b/src/lib/bits/combobox/ctx.ts
new file mode 100644
index 000000000..e1990ef9e
--- /dev/null
+++ b/src/lib/bits/combobox/ctx.ts
@@ -0,0 +1,135 @@
+import { type CreateComboboxProps, createCombobox } from "@melt-ui/svelte";
+import { getContext, setContext } from "svelte";
+import {
+ createBitAttrs,
+ generateId,
+ getOptionUpdater,
+ removeUndefined,
+} from "$lib/internal/index.js";
+import { getPositioningUpdater } from "../floating/helpers.js";
+import type { Writable } from "svelte/store";
+import type { FloatingConfig } from "../floating/floating-config.js";
+import type { FloatingProps } from "../floating/_types.js";
+
+function getSelectData() {
+ const NAME = "combobox" as const;
+ const GROUP_NAME = "combobox-group";
+ const ITEM_NAME = "combobox-item";
+
+ const PARTS = [
+ "content",
+ "menu",
+ "input",
+ "item",
+ "label",
+ "group",
+ "group-label",
+ "arrow",
+ "hidden-input",
+ "indicator",
+ ] as const;
+
+ return {
+ NAME,
+ GROUP_NAME,
+ ITEM_NAME,
+ PARTS,
+ };
+}
+
+type GetReturn = Omit, "updateOption">;
+
+export function getCtx() {
+ const { NAME } = getSelectData();
+ return getContext(NAME);
+}
+
+type Items = {
+ value: T;
+ label?: string;
+};
+
+type Props = CreateComboboxProps & {
+ items?: Items[];
+};
+
+export function setCtx(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ props: Props
+) {
+ const { NAME, PARTS } = getSelectData();
+ const getAttrs = createBitAttrs(NAME, PARTS);
+
+ const combobox = {
+ ...createCombobox({ ...removeUndefined(props), forceVisible: true }),
+ getAttrs,
+ };
+ setContext(NAME, combobox);
+ return {
+ ...combobox,
+ updateOption: getOptionUpdater(combobox.options),
+ };
+}
+
+export function setGroupCtx() {
+ const { GROUP_NAME } = getSelectData();
+ const id = generateId();
+ setContext(GROUP_NAME, id);
+ const {
+ elements: { group },
+ getAttrs,
+ } = getCtx();
+ return { group, id, getAttrs };
+}
+
+export function setItemCtx(value: unknown) {
+ const { ITEM_NAME } = getSelectData();
+ const combobox = getCtx();
+ setContext(ITEM_NAME, value);
+ return combobox;
+}
+
+export function getGroupLabel() {
+ const { GROUP_NAME } = getSelectData();
+ const id = getContext(GROUP_NAME);
+ const {
+ elements: { groupLabel },
+ getAttrs,
+ } = getCtx();
+ return { groupLabel, id, getAttrs };
+}
+
+export function getItemIndicator() {
+ const { ITEM_NAME } = getSelectData();
+ const {
+ helpers: { isSelected },
+ getAttrs,
+ } = getCtx();
+ const value = getContext(ITEM_NAME);
+ return {
+ value,
+ isSelected,
+ getAttrs,
+ };
+}
+
+export function setArrow(size = 8) {
+ const combobox = getCtx();
+ combobox.options.arrowSize?.set(size);
+ return combobox;
+}
+
+export function updatePositioning(props: FloatingProps) {
+ const defaultPlacement = {
+ side: "bottom",
+ align: "center",
+ sameWidth: true,
+ } satisfies FloatingProps;
+ const withDefaults = { ...defaultPlacement, ...props } satisfies FloatingProps;
+ const {
+ options: { positioning },
+ } = getCtx();
+
+ const updater = getPositioningUpdater(positioning as Writable);
+ updater(withDefaults);
+}
diff --git a/src/lib/bits/combobox/index.ts b/src/lib/bits/combobox/index.ts
new file mode 100644
index 000000000..58ebc05fa
--- /dev/null
+++ b/src/lib/bits/combobox/index.ts
@@ -0,0 +1,13 @@
+export { default as Root } from "./components/combobox.svelte";
+export { default as Content } from "./components/combobox-content.svelte";
+export { default as Input } from "./components/combobox-input.svelte";
+export { default as Item } from "./components/combobox-item.svelte";
+export { default as Label } from "./components/combobox-label.svelte";
+export { default as Group } from "./components/combobox-group.svelte";
+export { default as GroupLabel } from "./components/combobox-group-label.svelte";
+export { default as Arrow } from "./components/combobox-arrow.svelte";
+export { default as HiddenInput } from "./components/combobox-hidden-input.svelte";
+export { default as Separator } from "../separator/components/separator.svelte";
+export { default as ItemIndicator } from "./components/combobox-item-indicator.svelte";
+
+export * from "./types.js";
diff --git a/src/lib/bits/combobox/types.ts b/src/lib/bits/combobox/types.ts
new file mode 100644
index 000000000..b2b17dc39
--- /dev/null
+++ b/src/lib/bits/combobox/types.ts
@@ -0,0 +1,68 @@
+import type { HTMLDivAttributes, Transition } from "$lib/internal/index.js";
+import type { EventHandler, HTMLInputAttributes } from "svelte/elements";
+import type { CustomEventHandler } from "$lib/index.js";
+import type * as I from "./_types.js";
+
+type Props = I.Props;
+
+type ContentProps<
+ T extends Transition = Transition,
+ In extends Transition = Transition,
+ Out extends Transition = Transition
+> = I.ContentProps & HTMLDivAttributes;
+
+type InputProps = I.InputProps & HTMLInputAttributes;
+type LabelProps = I.LabelProps & HTMLDivAttributes;
+
+type GroupProps = I.GroupProps & HTMLDivAttributes;
+type GroupLabelProps = I.GroupLabelProps & HTMLDivAttributes;
+
+type ItemProps = I.ItemProps & HTMLDivAttributes;
+
+type HiddenInputProps = I.HiddenInputProps & HTMLInputAttributes;
+type SeparatorProps = I.SeparatorProps & HTMLDivAttributes;
+type IndicatorProps = I.IndicatorProps & HTMLDivAttributes;
+type ArrowProps = I.ArrowProps & HTMLDivAttributes;
+
+type ItemEvents = {
+ click: CustomEventHandler;
+ pointermove: CustomEventHandler;
+ focusin: EventHandler;
+ keydown: EventHandler;
+ focusout: EventHandler;
+ pointerleave: EventHandler;
+};
+
+type ContentEvents = {
+ pointerleave: CustomEventHandler;
+ keydown: EventHandler;
+};
+
+type GroupLabelEvents = {
+ click: CustomEventHandler;
+};
+
+type InputEvents = {
+ keydown: CustomEventHandler;
+ input: CustomEventHandler;
+ click: CustomEventHandler;
+};
+
+export type {
+ Props,
+ ContentProps,
+ InputProps,
+ ItemProps,
+ LabelProps,
+ GroupProps,
+ GroupLabelProps,
+ ArrowProps,
+ HiddenInputProps,
+ SeparatorProps,
+ IndicatorProps,
+ //
+ ContentEvents,
+ InputEvents,
+ ItemEvents,
+ GroupLabelEvents,
+};
diff --git a/src/lib/bits/index.ts b/src/lib/bits/index.ts
index f46892f99..38d0f4348 100644
--- a/src/lib/bits/index.ts
+++ b/src/lib/bits/index.ts
@@ -6,6 +6,7 @@ export * as Button from "./button/index.js";
export * as Calendar from "./calendar/index.js";
export * as Checkbox from "./checkbox/index.js";
export * as Collapsible from "./collapsible/index.js";
+export * as Combobox from "./combobox/index.js";
export * as ContextMenu from "./context-menu/index.js";
export * as DateField from "./date-field/index.js";
export * as DatePicker from "./date-picker/index.js";
diff --git a/src/tests/combobox/Combobox.spec.ts b/src/tests/combobox/Combobox.spec.ts
new file mode 100644
index 000000000..4fd777396
--- /dev/null
+++ b/src/tests/combobox/Combobox.spec.ts
@@ -0,0 +1,282 @@
+import { render, waitFor } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { axe } from "jest-axe";
+import { describe, it } from "vitest";
+import ComboboxTest from "./ComboboxTest.svelte";
+import type { Item } from "./ComboboxTest.svelte";
+import { getTestKbd } from "../utils.js";
+import type { Combobox } from "$lib";
+
+const kbd = getTestKbd();
+
+const testItems: Item[] = [
+ {
+ value: "1",
+ label: "A",
+ },
+ {
+ value: "2",
+ label: "B",
+ },
+ {
+ value: "3",
+ label: "C",
+ },
+ {
+ value: "4",
+ label: "D",
+ },
+];
+
+function setup(props: Combobox.Props = {}, options: Item[] = testItems) {
+ const user = userEvent.setup();
+ const returned = render(ComboboxTest, { ...props, options });
+ const input = returned.getByTestId("input");
+ const hiddenInput = returned.getByTestId("hidden-input");
+ return {
+ input,
+ user,
+ hiddenInput,
+ ...returned,
+ };
+}
+async function open(
+ props: Combobox.Props = {},
+ openWith: "click" | "type" | (string & {}) = "click",
+ inputValue?: string
+) {
+ const returned = setup(props);
+ const { input, getByTestId, queryByTestId, user } = returned;
+ expect(queryByTestId("content")).toBeNull();
+ if (openWith === "click") {
+ await user.click(input);
+ } else if (openWith === "type" && inputValue) {
+ await user.type(input, inputValue);
+ } else {
+ input.focus();
+ await user.keyboard(openWith);
+ }
+ await waitFor(() => expect(queryByTestId("content")).not.toBeNull());
+ const menu = getByTestId("content");
+ return { menu, ...returned };
+}
+
+const OPEN_KEYS = [kbd.ARROW_DOWN, kbd.ARROW_UP];
+
+describe("Combobox", () => {
+ it("has no accessibility violations", async () => {
+ const { container } = render(ComboboxTest);
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it("has bits data attrs", async () => {
+ const { getByTestId } = await open();
+ const parts = ["content", "input", "group", "group-label"];
+
+ parts.forEach((part) => {
+ const el = getByTestId(part);
+ expect(el).toHaveAttribute(`data-combobox-${part}`);
+ });
+
+ const item = getByTestId("1");
+ expect(item).toHaveAttribute("data-combobox-item");
+ 1;
+ });
+
+ it("opens on click", async () => {
+ await open();
+ });
+
+ it.each(OPEN_KEYS)("opens on %s keydown", async (key) => {
+ await open({}, key);
+ });
+
+ it("doesnt display the hidden input", async () => {
+ const { hiddenInput } = await open();
+ expect(hiddenInput).not.toBeVisible();
+ });
+
+ it("selects item with the enter key", async () => {
+ const { user, queryByTestId, getByTestId } = await open();
+ await user.keyboard(kbd.ARROW_DOWN);
+ await user.keyboard(kbd.ENTER);
+ await waitFor(() => expect(queryByTestId("content")).toBeNull());
+ expect(getByTestId("input")).toHaveValue("A");
+ });
+
+ it("syncs the name prop to the hidden input", async () => {
+ const { hiddenInput } = setup({ name: "test" });
+ expect(hiddenInput).toHaveAttribute("name", "test");
+ });
+
+ it("syncs the value prop to the hidden input", async () => {
+ const { hiddenInput } = setup({ selected: { value: "test" } });
+ expect(hiddenInput).toHaveValue("test");
+ });
+
+ it("syncs the required prop to the hidden input", async () => {
+ const { hiddenInput } = setup({ required: true });
+ expect(hiddenInput).toHaveAttribute("required");
+ });
+
+ it("syncs the disabled prop to the hidden input", async () => {
+ const { hiddenInput } = setup({ disabled: true });
+ await waitFor(() => expect(hiddenInput).toHaveAttribute("disabled", ""));
+ });
+
+ it("closes on escape keydown", async () => {
+ const { user, queryByTestId } = await open();
+ await user.keyboard(kbd.ESCAPE);
+ expect(queryByTestId("content")).toBeNull();
+ });
+
+ it("closes on outside click", async () => {
+ const { user, queryByTestId, getByTestId } = await open();
+ const outside = getByTestId("outside");
+ await user.click(outside);
+ expect(queryByTestId("content")).toBeNull();
+ });
+
+ it("portals to the body by default", async () => {
+ const { menu } = await open();
+ expect(menu.parentElement).toBe(document.body);
+ });
+
+ it("portals to a custom element if specified", async () => {
+ const { menu, getByTestId } = await open({ portal: "#portal-target" });
+ const portalTarget = getByTestId("portal-target");
+ expect(menu.parentElement).toBe(portalTarget);
+ });
+
+ it("does not portal if `null` is passed as portal prop", async () => {
+ const { menu, getByTestId } = await open({ portal: null });
+ const main = getByTestId("main");
+ expect(menu.parentElement).toBe(main);
+ });
+
+ it("respects the `closeOnEscape` prop", async () => {
+ const { user, queryByTestId } = await open({ closeOnEscape: false });
+ await user.keyboard(kbd.ESCAPE);
+ await waitFor(() => expect(queryByTestId("content")).not.toBeNull());
+ });
+
+ it('respects the "closeOnOutsideClick" prop', async () => {
+ const { user, queryByTestId, getByTestId } = await open({
+ closeOnOutsideClick: false,
+ });
+ const outside = getByTestId("outside");
+ await user.click(outside);
+ await waitFor(() => expect(queryByTestId("content")).not.toBeNull());
+ });
+
+ it("respects binding the `inputValue` prop", async () => {
+ const { getByTestId, user } = await open({ inputValue: "A" });
+ const binding = getByTestId("input-binding");
+ expect(binding).toHaveTextContent("A");
+ await user.click(binding);
+ expect(binding).toHaveTextContent("empty");
+ });
+
+ it("respects binding the `open` prop", async () => {
+ const { queryByTestId, getByTestId, user } = await open({ closeOnOutsideClick: false });
+ const binding = getByTestId("open-binding");
+ expect(binding).toHaveTextContent("true");
+ await user.click(binding);
+ expect(binding).toHaveTextContent("false");
+ await waitFor(() => expect(queryByTestId("content")).toBeNull());
+ await user.click(binding);
+ expect(binding).toHaveTextContent("true");
+ await waitFor(() => expect(queryByTestId("content")).not.toBeNull());
+ });
+
+ it("respects binding the `selected` prop", async () => {
+ const { getByTestId, user } = await open({ selected: { label: "A", value: "1" } });
+ const binding = getByTestId("selected-binding");
+ expect(binding).toHaveTextContent("1");
+ await user.click(binding);
+ expect(binding).toHaveTextContent("undefined");
+ });
+
+ it("selects items when clicked", async () => {
+ const { getByTestId, user, queryByTestId, hiddenInput, input } = await open();
+ const item = getByTestId("1");
+ await waitFor(() => expect(queryByTestId("1-indicator")).toBeNull());
+ await user.click(item);
+ await waitFor(() => expect(queryByTestId("content")).toBeNull());
+ expect(input).toHaveValue("A");
+ expect(hiddenInput).toHaveValue("1");
+ await user.click(input);
+ await waitFor(() => expect(queryByTestId("content")).not.toBeNull());
+ expect(item).toHaveAttribute("aria-selected", "true");
+ expect(item).toHaveAttribute("data-selected");
+ await waitFor(() => expect(queryByTestId("1-indicator")).not.toBeNull());
+ });
+
+ it("navigates through the items using the keyboard", async () => {
+ const { getByTestId, user } = await open({}, kbd.ARROW_DOWN);
+
+ const item0 = getByTestId("1");
+ const item1 = getByTestId("2");
+ const item2 = getByTestId("3");
+ const item3 = getByTestId("4");
+ await user.keyboard(kbd.ARROW_DOWN);
+ await waitFor(() => expect(item0).toHaveAttribute("data-highlighted"));
+ await user.keyboard(kbd.ARROW_DOWN);
+ await waitFor(() => expect(item1).toHaveAttribute("data-highlighted"));
+ await user.keyboard(kbd.ARROW_DOWN);
+ await waitFor(() => expect(item2).toHaveAttribute("data-highlighted"));
+ await user.keyboard(kbd.ARROW_DOWN);
+ await waitFor(() => expect(item3).toHaveAttribute("data-highlighted"));
+ await user.keyboard(kbd.ARROW_UP);
+ await waitFor(() => expect(item2).toHaveAttribute("data-highlighted"));
+ await user.keyboard(kbd.ARROW_UP);
+ await waitFor(() => expect(item1).toHaveAttribute("data-highlighted"));
+ await user.keyboard(kbd.ARROW_UP);
+ await waitFor(() => expect(item0).toHaveAttribute("data-highlighted"));
+ });
+
+ it("allows items to be selected using the keyboard", async () => {
+ const { getByTestId, user, queryByTestId, hiddenInput, input } = await open({}, kbd.ARROW_DOWN);
+
+ const item0 = getByTestId("1");
+ const item1 = getByTestId("2");
+ const item2 = getByTestId("3");
+ const item3 = getByTestId("4");
+ await user.keyboard(kbd.ARROW_DOWN);
+ await user.keyboard(kbd.ARROW_DOWN);
+ await user.keyboard(kbd.ARROW_DOWN);
+ await user.keyboard(kbd.ENTER);
+ await waitFor(() => expect(queryByTestId("content")).toBeNull());
+ expect(input).toHaveValue("C");
+ expect(hiddenInput).toHaveValue("3");
+ await user.click(input);
+ await waitFor(() => expect(queryByTestId("content")).not.toBeNull());
+ expect(item0).not.toHaveAttribute("data-selected");
+ expect(item1).not.toHaveAttribute("data-selected");
+ expect(item2).toHaveAttribute("data-selected");
+ expect(item3).not.toHaveAttribute("data-selected");
+ });
+
+ it("applies the `data-highlighted` attribute on mouseover", async () => {
+ const { getByTestId, user } = await open({}, kbd.ARROW_DOWN);
+ const item1 = getByTestId("1");
+ const item2 = getByTestId("2");
+ await user.hover(item1);
+ await waitFor(() => expect(item1).toHaveAttribute("data-highlighted"));
+ await user.hover(item2);
+ await waitFor(() => expect(item2).toHaveAttribute("data-highlighted"));
+ await waitFor(() => expect(item1).not.toHaveAttribute("data-highlighted"));
+ });
+
+ it("selects a default item when provided", async () => {
+ const { getByTestId, queryByTestId, input, hiddenInput } = await open({
+ selected: { value: "2", label: "B" },
+ });
+ expect(queryByTestId("2-indicator")).not.toBeNull();
+ expect(input).toHaveValue("B");
+ expect(hiddenInput).toHaveValue("2");
+ const item = getByTestId("2");
+ expect(item).toHaveAttribute("aria-selected", "true");
+ expect(item).toHaveAttribute("data-selected");
+ });
+});
diff --git a/src/tests/combobox/ComboboxTest.svelte b/src/tests/combobox/ComboboxTest.svelte
new file mode 100644
index 000000000..870f966de
--- /dev/null
+++ b/src/tests/combobox/ComboboxTest.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+ Options
+ {#each options as { value, label, disabled }}
+
+
+ x
+
+ {label}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+