,
+ data: text,
+ },
+ });
+
+ if (hidden) {
+ return null;
+ }
+
+ return (
+ {
+ event.preventDefault();
+ if (!disabled) {
+ onMouseDown?.(event);
+ }
+ }}
+ onClick={(event) => {
+ if (disabled) {
+ return;
+ }
+
+ onClick?.(event);
+ if (!event.defaultPrevented) {
+ selectOption(text);
+ }
+ }}
+ onKeyDown={(event) => {
+ if (disabled) {
+ return;
+ }
+
+ onKeyDown?.(event);
+ if (
+ !event.defaultPrevented &&
+ (event.key === "Enter" || event.key === " ")
+ ) {
+ event.preventDefault();
+ selectOption(text);
+ }
+ }}
+ >
+ {text}
+
+ );
+}
diff --git a/packages/evo-react/src/combobox/combobox.stories.tsx b/packages/evo-react/src/combobox/combobox.stories.tsx
new file mode 100644
index 00000000000..26cb27a7d22
--- /dev/null
+++ b/packages/evo-react/src/combobox/combobox.stories.tsx
@@ -0,0 +1,133 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { EvoIconClear16 } from "../icon/icons/clear-16";
+import { EvoCombobox } from "./combobox";
+import { EvoComboboxOption } from "./combobox-option";
+
+const meta: Meta = {
+ title: "form input/evo-combobox",
+ component: EvoCombobox,
+ subcomponents: { EvoComboboxOption },
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component: `
+A text input with a filtered listbox of selectable options.
+
+## Usage
+
+\`\`\`tsx
+import {
+ EvoCombobox,
+ EvoComboboxOption,
+} from "@evo-web/react/combobox";
+\`\`\`
+ `,
+ },
+ },
+ },
+ argTypes: {
+ autocomplete: {
+ control: "select",
+ options: ["none", "list"],
+ },
+ listSelection: {
+ control: "select",
+ options: ["automatic", "manual"],
+ },
+ strategy: {
+ control: "select",
+ options: ["absolute", "fixed"],
+ },
+ borderless: {
+ control: "boolean",
+ },
+ defaultOpen: {
+ control: "boolean",
+ },
+ disabled: {
+ control: "boolean",
+ },
+ floatingLabel: {
+ control: "text",
+ },
+ fluid: {
+ control: "boolean",
+ },
+ open: {
+ control: "boolean",
+ },
+ postfix: {
+ control: false,
+ },
+ onValueChange: {
+ action: "valueChange",
+ table: { category: "Events" },
+ },
+ onOpenChange: {
+ action: "openChange",
+ table: { category: "Events" },
+ },
+ },
+ args: {
+ floatingLabel: "Campaign",
+ autocomplete: "none",
+ listSelection: "automatic",
+ placeholder: "Choose a campaign",
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ ),
+};
+
+export const Controlled: Story = {
+ args: {
+ value: "August Campaign",
+ },
+ render: (args) => (
+
+
+
+
+
+
+ ),
+};
+
+export const Postfix: Story = {
+ render: (args) => {
+ const [value, setValue] = useState("August Campaign");
+
+ return (
+ ,
+ buttonProps: {
+ a11yText: "Clear",
+ onClick: () => setValue(""),
+ },
+ }}
+ >
+
+
+
+
+
+ );
+ },
+};
diff --git a/packages/evo-react/src/combobox/combobox.tsx b/packages/evo-react/src/combobox/combobox.tsx
new file mode 100644
index 00000000000..1da46e8777d
--- /dev/null
+++ b/packages/evo-react/src/combobox/combobox.tsx
@@ -0,0 +1,399 @@
+import { useCallback, useEffect, useId, useState } from "react";
+import type {
+ ChangeEvent,
+ FocusEvent,
+ KeyboardEvent,
+ MouseEvent,
+ RefObject,
+} from "react";
+import classNames from "classnames";
+import { EvoIconButton } from "../icon-button";
+import { useActiveDescendant } from "../utils/use-active-descendant";
+import { useExpander } from "../utils/use-expander";
+import { useFloatingLabel } from "../utils/use-floating-label";
+import { useRefTee } from "../utils/use-ref-tee";
+import { ComboboxProvider } from "./context";
+import type { EvoComboboxProps } from "./types";
+import "@ebay/skin/combobox.mjs";
+
+type TemporaryValue = {
+ key: string;
+ origin: string;
+ value: string;
+};
+
+const activeDescendantScrollIntoView: ScrollIntoViewOptions = {
+ block: "nearest",
+};
+
+export function EvoCombobox({
+ autocomplete = "none",
+ borderless = false,
+ children,
+ className,
+ defaultOpen = false,
+ defaultValue = "",
+ disabled,
+ floatingLabel: floatingLabelText,
+ fluid = false,
+ id,
+ listSelection = "automatic",
+ onBlur,
+ onChange,
+ onClick,
+ onFocus,
+ onKeyDown,
+ onOpenChange,
+ onValueChange,
+ open,
+ placeholder,
+ postfix,
+ ref,
+ strategy = "absolute",
+ style,
+ value,
+ ...inputProps
+}: EvoComboboxProps) {
+ const generatedId = useId();
+ const inputId = id ?? generatedId;
+ const listboxId = `${inputId}-listbox`;
+ const isControlled = value !== undefined;
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
+ const [temporaryValue, setTemporaryValue] = useState(
+ null,
+ );
+ const [focused, setFocused] = useState(false);
+ const currentValue = isControlled ? value : uncontrolledValue;
+ const expander = useExpander({
+ defaultOpen,
+ offset: 4,
+ onOpenChange,
+ open,
+ placement: "bottom-start",
+ resetOnDisabled: Boolean(disabled),
+ strategy,
+ });
+ const effectiveOpen = !disabled && expander.open;
+ const [setListboxElement, listboxRef] = useRefTee(
+ expander.refs.setFloating,
+ null,
+ );
+ const [setForwardedInputElement] = useRefTee(
+ ref,
+ null,
+ );
+ const activeDescendant = useActiveDescendant({
+ containerRef: listboxRef as RefObject,
+ scrollIntoView: activeDescendantScrollIntoView,
+ shouldWrap: true,
+ });
+ const activeOption = effectiveOpen
+ ? activeDescendant.getActiveItem()
+ : undefined;
+ const hasValidTemporaryValue =
+ temporaryValue !== null &&
+ listSelection === "automatic" &&
+ temporaryValue.origin === currentValue &&
+ temporaryValue.key === activeOption?.key;
+
+ if (temporaryValue !== null && !hasValidTemporaryValue) {
+ setTemporaryValue(null);
+ }
+
+ const displayedValue = hasValidTemporaryValue
+ ? temporaryValue.value
+ : currentValue;
+ const previewValue = hasValidTemporaryValue ? temporaryValue.value : null;
+
+ useEffect(() => {
+ if (disabled) {
+ activeDescendant.reset();
+ }
+ }, [activeDescendant.reset, disabled]);
+
+ const floatingLabel = useFloatingLabel({
+ containerTagName: fluid ? "div" : "span",
+ disabled,
+ focused: focused || effectiveOpen,
+ text: floatingLabelText,
+ value: displayedValue,
+ });
+
+ const setInputElement = useCallback(
+ (node: HTMLInputElement | null) => {
+ expander.refs.setReference(node);
+ setForwardedInputElement(node);
+ },
+ [expander.refs.setReference, setForwardedInputElement],
+ );
+
+ const requestOpen = useCallback(
+ (nextOpen: boolean) => {
+ if (disabled && nextOpen) {
+ return;
+ }
+
+ if (nextOpen !== expander.open) {
+ expander.setOpen(nextOpen);
+ }
+ },
+ [disabled, expander.open, expander.setOpen],
+ );
+
+ const updateValue = useCallback(
+ (nextValue: string) => {
+ setTemporaryValue(null);
+ if (!isControlled) {
+ setUncontrolledValue(nextValue);
+ }
+ if (nextValue !== currentValue) {
+ onValueChange?.(nextValue);
+ }
+ },
+ [currentValue, isControlled, onValueChange],
+ );
+
+ const selectOption = useCallback(
+ (text: string) => {
+ if (disabled) {
+ return;
+ }
+
+ updateValue(text);
+ activeDescendant.reset();
+ requestOpen(false);
+ },
+ [activeDescendant.reset, disabled, requestOpen, updateValue],
+ );
+
+ const handleFocusOut = useCallback(
+ (event: FocusEvent) => {
+ if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
+ return;
+ }
+
+ setFocused(false);
+ requestOpen(false);
+ activeDescendant.reset();
+
+ if (previewValue !== null) {
+ updateValue(previewValue);
+ }
+ },
+ [activeDescendant.reset, previewValue, requestOpen, updateValue],
+ );
+
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ activeDescendant.reset();
+ updateValue(event.currentTarget.value);
+ requestOpen(true);
+ onChange?.(event);
+ },
+ [activeDescendant.reset, disabled, onChange, requestOpen, updateValue],
+ );
+
+ const handleFocus = useCallback(
+ (event: FocusEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ setFocused(true);
+ requestOpen(true);
+ onFocus?.(event);
+ },
+ [disabled, onFocus, requestOpen],
+ );
+
+ const handleBlur = useCallback(
+ (event: FocusEvent) => {
+ onBlur?.(event);
+ },
+ [onBlur],
+ );
+
+ const handleClick = useCallback(
+ (event: MouseEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ requestOpen(true);
+ onClick?.(event);
+ },
+ [disabled, onClick, requestOpen],
+ );
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ if (!effectiveOpen) {
+ requestOpen(true);
+ } else {
+ const option =
+ event.key === "ArrowDown"
+ ? activeDescendant.activateNext()
+ : activeDescendant.activatePrevious();
+ if (listSelection === "automatic") {
+ setTemporaryValue(
+ option
+ ? {
+ key: option.key,
+ origin: currentValue,
+ value: option.data,
+ }
+ : null,
+ );
+ }
+ }
+ } else if (event.key === "Enter" && effectiveOpen) {
+ const option = activeDescendant.getActiveItem();
+ if (option) {
+ event.preventDefault();
+ selectOption(option.data);
+ } else {
+ requestOpen(false);
+ }
+ } else if (event.key === "Escape") {
+ setTemporaryValue(null);
+ activeDescendant.reset();
+ requestOpen(false);
+ }
+
+ onKeyDown?.(event);
+ },
+ [
+ activeDescendant.activateNext,
+ activeDescendant.activatePrevious,
+ activeDescendant.getActiveItem,
+ activeDescendant.reset,
+ currentValue,
+ disabled,
+ effectiveOpen,
+ listSelection,
+ onKeyDown,
+ requestOpen,
+ selectOption,
+ ],
+ );
+
+ const Wrapper = fluid ? "div" : "span";
+ const postfixButtonProps = postfix?.buttonProps;
+ const postfixMouseDown = postfixButtonProps?.onMouseDown;
+ const postfixClick = postfixButtonProps?.onClick;
+ const handlePostfixClick = useCallback(
+ (event: MouseEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ setTemporaryValue(null);
+ activeDescendant.reset();
+ postfixClick?.(event);
+ },
+ [activeDescendant.reset, disabled, postfixClick],
+ );
+ const handlePostfixMouseDown = useCallback(
+ (event: MouseEvent) => {
+ event.preventDefault();
+ if (!disabled) {
+ postfixMouseDown?.(event);
+ }
+ },
+ [disabled, postfixMouseDown],
+ );
+
+ return (
+
+
+
+
+
+
+ {postfix &&
+ (postfixButtonProps ? (
+
+ {postfix.icon}
+
+ ) : (
+ postfix.icon
+ ))}
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/packages/evo-react/src/combobox/context.tsx b/packages/evo-react/src/combobox/context.tsx
new file mode 100644
index 00000000000..94a07ecf14a
--- /dev/null
+++ b/packages/evo-react/src/combobox/context.tsx
@@ -0,0 +1,62 @@
+import { createContext, use, useMemo } from "react";
+import type { ReactNode } from "react";
+import type { ActiveDescendant } from "../utils/use-active-descendant";
+import type { ComboboxAutocomplete } from "./types";
+
+export type ComboboxContextValue = {
+ activeDescendant: ActiveDescendant;
+ autocomplete: ComboboxAutocomplete;
+ displayedValue: string;
+ filterValue: string;
+ disabled?: boolean;
+ selectOption: (text: string) => void;
+};
+
+const ComboboxContext = createContext(
+ undefined,
+);
+
+export function useComboboxContext() {
+ const context = use(ComboboxContext);
+ if (!context) {
+ throw new Error(
+ "EvoComboboxOption must be used within an EvoCombobox component",
+ );
+ }
+ return context;
+}
+
+type ComboboxProviderProps = ComboboxContextValue & {
+ children?: ReactNode;
+};
+
+export function ComboboxProvider({
+ activeDescendant,
+ autocomplete,
+ displayedValue,
+ filterValue,
+ disabled,
+ selectOption,
+ children,
+}: ComboboxProviderProps) {
+ const value = useMemo(
+ () => ({
+ activeDescendant,
+ autocomplete,
+ displayedValue,
+ filterValue,
+ disabled,
+ selectOption,
+ }),
+ [
+ activeDescendant,
+ autocomplete,
+ disabled,
+ displayedValue,
+ filterValue,
+ selectOption,
+ ],
+ );
+
+ return {children};
+}
diff --git a/packages/evo-react/src/combobox/index.ts b/packages/evo-react/src/combobox/index.ts
new file mode 100644
index 00000000000..5430e70ee07
--- /dev/null
+++ b/packages/evo-react/src/combobox/index.ts
@@ -0,0 +1,10 @@
+export { EvoCombobox } from "./combobox";
+export { EvoComboboxOption } from "./combobox-option";
+export type {
+ ComboboxAutocomplete,
+ ComboboxListSelection,
+ ComboboxStrategy,
+ EvoComboboxOptionProps,
+ EvoComboboxPostfixProps,
+ EvoComboboxProps,
+} from "./types";
diff --git a/packages/evo-react/src/combobox/test/__snapshots__/test.server.tsx.snap b/packages/evo-react/src/combobox/test/__snapshots__/test.server.tsx.snap
new file mode 100644
index 00000000000..192ad2859f2
--- /dev/null
+++ b/packages/evo-react/src/combobox/test/__snapshots__/test.server.tsx.snap
@@ -0,0 +1,13 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`EvoCombobox SSR > renders a disabled combobox as closed 1`] = `"August Campaign
Basic Offer
"`;
+
+exports[`EvoCombobox SSR > renders a fixed-strategy listbox 1`] = `"August Campaign
Basic Offer
"`;
+
+exports[`EvoCombobox SSR > renders a fluid combobox with a floating label 1`] = `""`;
+
+exports[`EvoCombobox SSR > renders an actionable postfix 1`] = `"August Campaign
Basic Offer
"`;
+
+exports[`EvoCombobox SSR > renders an open borderless combobox 1`] = `"August Campaign
Basic Offer
"`;
+
+exports[`EvoCombobox SSR > renders the default combobox 1`] = `"August Campaign
Basic Offer
"`;
diff --git a/packages/evo-react/src/combobox/test/test.browser.tsx b/packages/evo-react/src/combobox/test/test.browser.tsx
new file mode 100644
index 00000000000..87b2d404b46
--- /dev/null
+++ b/packages/evo-react/src/combobox/test/test.browser.tsx
@@ -0,0 +1,619 @@
+import { createRef, useRef, useState } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { userEvent } from "vitest/browser";
+import { render } from "vitest-browser-react";
+import { EvoIconClose12 } from "../../icon/icons/close-12";
+import { EvoCombobox } from "../combobox";
+import { EvoComboboxOption } from "../combobox-option";
+import type { EvoComboboxProps } from "../types";
+
+function ComboboxFixture(props: EvoComboboxProps) {
+ return (
+
+
+
+
+ );
+}
+
+describe("evo-combobox", () => {
+ let user: ReturnType;
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
+
+ afterEach(() => {
+ user.cleanup();
+ vi.restoreAllMocks();
+ });
+
+ describe("ARIA attributes", () => {
+ it("links the combobox to its listbox", async () => {
+ const screen = await render();
+ const input = screen.getByRole("combobox", { name: "Campaign" });
+
+ await user.click(input);
+
+ const listbox = screen.getByRole("listbox");
+ await expect.element(input).toHaveAttribute("aria-expanded", "true");
+ await expect
+ .element(input)
+ .toHaveAttribute("aria-controls", listbox.element().id);
+ await expect
+ .element(input)
+ .toHaveAttribute("aria-owns", listbox.element().id);
+ });
+
+ it("sets the active descendant during keyboard navigation", async () => {
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+
+ const option = screen.getByRole("option", { name: "August Campaign" });
+ await expect
+ .element(input)
+ .toHaveAttribute("aria-activedescendant", option.element().id);
+ await expect.element(option).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("scrolls each newly active option without scrolling on reset", async () => {
+ const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+ await user.keyboard("{ArrowDown}");
+
+ expect(scrollIntoView).toHaveBeenCalledTimes(2);
+ expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "nearest" });
+
+ await user.keyboard("{Escape}");
+ expect(scrollIntoView).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe("value changes", () => {
+ it("passes the displayed value to onValueChange while typing", async () => {
+ const onValueChange = vi.fn();
+ const screen = await render(
+ ,
+ );
+
+ await user.type(screen.getByRole("combobox"), "Basic");
+
+ expect(onValueChange).toHaveBeenLastCalledWith("Basic");
+ await expect.element(screen.getByRole("combobox")).toHaveValue("Basic");
+ });
+
+ it("passes the native change event to onChange while typing", async () => {
+ const onChange = vi.fn();
+ const screen = await render(
+ onChange(event.currentTarget.value)}
+ />,
+ );
+
+ await user.type(screen.getByRole("combobox"), "B");
+
+ expect(onChange).toHaveBeenCalledWith("B");
+ });
+
+ it("passes selected option text only to onValueChange", async () => {
+ const onChange = vi.fn();
+ const onValueChange = vi.fn();
+ const screen = await render(
+ ,
+ );
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.click(screen.getByRole("option", { name: "August Campaign" }));
+
+ expect(onValueChange).toHaveBeenCalledWith("August Campaign");
+ expect(onChange).not.toHaveBeenCalled();
+ await expect.element(input).toHaveValue("August Campaign");
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("supports controlled values for typing and selection", async () => {
+ function ControlledCombobox() {
+ const [value, setValue] = useState("");
+ return ;
+ }
+
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.type(input, "B");
+ await expect.element(input).toHaveValue("B");
+ await user.click(screen.getByRole("option", { name: "Basic Offer" }));
+ await expect.element(input).toHaveValue("Basic Offer");
+ });
+
+ it("does not let a controlled update leave a stale keyboard preview", async () => {
+ function ControlledCombobox() {
+ const [value, setValue] = useState("");
+ return (
+ {
+ if (event.key === "ArrowDown") {
+ setValue("Externally changed");
+ }
+ }}
+ />
+ );
+ }
+
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+
+ await expect.element(input).toHaveValue("Externally changed");
+ });
+
+ it("does not let an active option removal leave a stale preview", async () => {
+ function FilteredCombobox() {
+ const [showFirst, setShowFirst] = useState(true);
+ return (
+ {
+ if (event.key === "ArrowDown") {
+ setShowFirst(false);
+ }
+ }}
+ >
+ {showFirst && }
+
+
+ );
+ }
+
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+
+ await expect.element(input).toHaveValue("");
+ });
+ });
+
+ describe("filtering", () => {
+ it("filters options by text when autocomplete is list", async () => {
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.type(input, "Basic");
+
+ await expect
+ .element(screen.getByRole("option", { name: "August Campaign" }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("option", { name: "Basic Offer" }))
+ .toBeInTheDocument();
+ });
+
+ it("keeps sticky options visible when they do not match", async () => {
+ const screen = await render(
+
+
+
+ ,
+ );
+
+ await user.type(screen.getByRole("combobox"), "missing");
+
+ await expect
+ .element(screen.getByRole("option", { name: "Create campaign" }))
+ .toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("option", { name: "Basic Offer" }))
+ .not.toBeInTheDocument();
+ });
+ });
+
+ describe("keyboard interactions", () => {
+ it("previews and selects options in automatic mode", async () => {
+ const onValueChange = vi.fn();
+ const screen = await render(
+ ,
+ );
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+
+ await expect.element(input).toHaveValue("August Campaign");
+ expect(onValueChange).not.toHaveBeenCalled();
+
+ await user.keyboard("{Enter}");
+
+ expect(onValueChange).toHaveBeenCalledWith("August Campaign");
+ });
+
+ it("does not preview option text in manual mode", async () => {
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+
+ await expect.element(input).toHaveValue("");
+ await expect.element(input).toHaveAttribute("aria-activedescendant");
+ });
+
+ it("restores the committed value on Escape", async () => {
+ const screen = await render(
+ ,
+ );
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+ await expect.element(input).toHaveValue("August Campaign");
+
+ await user.keyboard("{Escape}");
+
+ await expect.element(input).toHaveValue("Initial value");
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("clears a preview when list selection changes to manual", async () => {
+ function ChangingSelectionCombobox() {
+ const [selection, setSelection] =
+ useState("automatic");
+ const arrowPresses = useRef(0);
+
+ return (
+ {
+ if (event.key === "ArrowDown" && ++arrowPresses.current === 2) {
+ setSelection("manual");
+ }
+ }}
+ >
+
+
+
+ );
+ }
+
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+ await expect.element(input).toHaveValue("August Campaign");
+ await user.keyboard("{ArrowDown}");
+
+ await expect.element(input).toHaveValue("");
+ });
+
+ it("commits preview before native onBlur and allows restoration", async () => {
+ const onValueChange = vi.fn();
+
+ function ControlledCombobox() {
+ const [value, setValue] = useState("Initial value");
+ return (
+ {
+ onValueChange(nextValue);
+ setValue(nextValue);
+ }}
+ onBlur={() => setValue("Restored value")}
+ />
+ );
+ }
+
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+ await user.tab();
+
+ await expect.element(input).toHaveValue("Restored value");
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange).toHaveBeenCalledWith("August Campaign");
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ });
+ });
+
+ describe("disabled state", () => {
+ it("keeps a default-open combobox closed when disabled", async () => {
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+ const wrapper = screen.container.querySelector(".combobox");
+
+ await expect.element(input).toBeDisabled();
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ expect(wrapper).not.toHaveClass("combobox--expanded");
+ });
+
+ it("does not expose a controlled open state while disabled", async () => {
+ const screen = await render(
+ ,
+ );
+ const input = screen.getByRole("combobox");
+ const wrapper = screen.container.querySelector(".combobox");
+
+ await expect.element(input).toBeDisabled();
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ expect(wrapper).not.toHaveClass("combobox--expanded");
+ });
+
+ it("does not open from focus, click, or keyboard input while disabled", async () => {
+ const onOpenChange = vi.fn();
+ const onFocus = vi.fn();
+ const screen = await render(
+ ,
+ );
+ const input = screen.getByRole("combobox");
+
+ input.element().dispatchEvent(new FocusEvent("focus", { bubbles: true }));
+ input.element().dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ input
+ .element()
+ .dispatchEvent(
+ new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }),
+ );
+
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ expect(onOpenChange).not.toHaveBeenCalled();
+ expect(onFocus).not.toHaveBeenCalled();
+ });
+
+ it("clears active state and stays closed after an uncontrolled combobox is re-enabled", async () => {
+ function ToggleableCombobox() {
+ const [disabled, setDisabled] = useState(false);
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+ const toggle = screen.getByRole("button", { name: "Toggle disabled" });
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+ await expect.element(input).toHaveAttribute("aria-activedescendant");
+
+ await user.click(toggle);
+ await expect.element(input).toBeDisabled();
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ await expect.element(input).not.toHaveAttribute("aria-activedescendant");
+
+ await user.click(toggle);
+ await expect.element(input).not.toBeDisabled();
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ await expect.element(input).not.toHaveAttribute("aria-activedescendant");
+ });
+
+ it("follows a controlled open state after being re-enabled", async () => {
+ function ToggleableCombobox() {
+ const [disabled, setDisabled] = useState(true);
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+ const toggle = screen.getByRole("button", { name: "Toggle disabled" });
+
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ await user.click(toggle);
+ await expect.element(input).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("does not invoke disabled option handlers or select an option", async () => {
+ const onClick = vi.fn();
+ const onKeyDown = vi.fn();
+ const onMouseDown = vi.fn();
+ const onValueChange = vi.fn();
+ const screen = await render(
+
+
+ ,
+ );
+ const option =
+ screen.container.querySelector('[role="option"]');
+
+ option?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+ option?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ option?.dispatchEvent(
+ new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
+ );
+
+ expect(onClick).not.toHaveBeenCalled();
+ expect(onKeyDown).not.toHaveBeenCalled();
+ expect(onMouseDown).not.toHaveBeenCalled();
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("postfix action", () => {
+ it("runs the button callback without closing the listbox", async () => {
+ const onClick = vi.fn();
+ const screen = await render(
+ ,
+ buttonProps: { a11yText: "Clear", onClick },
+ }}
+ />,
+ );
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.click(screen.getByRole("button", { name: "Clear" }));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ await expect.element(input).toHaveFocus();
+ await expect.element(input).toHaveAttribute("aria-expanded", "true");
+ });
+
+ it("does not open or focus the input from a closed postfix action", async () => {
+ const onClick = vi.fn();
+ const screen = await render(
+ ,
+ buttonProps: { a11yText: "Action", onClick },
+ }}
+ />,
+ );
+ const input = screen.getByRole("combobox");
+
+ await user.click(screen.getByRole("button", { name: "Action" }));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ await expect.element(input).not.toHaveFocus();
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("keeps keyboard focus on the postfix after activation", async () => {
+ const screen = await render(
+ ,
+ buttonProps: { a11yText: "Action" },
+ }}
+ />,
+ );
+ const button = screen.getByRole("button", { name: "Action" });
+ const input = screen.getByRole("combobox");
+
+ button.element().focus();
+ await user.keyboard("{Enter}");
+
+ await expect.element(button).toHaveFocus();
+ await expect.element(input).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("clears keyboard preview before a controlled clear callback", async () => {
+ function ControlledCombobox() {
+ const [value, setValue] = useState("");
+ return (
+ ,
+ buttonProps: {
+ a11yText: "Clear",
+ onClick: () => setValue(""),
+ },
+ }}
+ />
+ );
+ }
+
+ const screen = await render();
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+ await user.keyboard("{ArrowDown}");
+ await expect.element(input).toHaveValue("August Campaign");
+
+ await user.click(screen.getByRole("button", { name: "Clear" }));
+
+ await expect.element(input).toHaveValue("");
+ await expect.element(input).toHaveFocus();
+ });
+
+ it("does not run a disabled postfix callback", async () => {
+ const onClick = vi.fn();
+ const screen = await render(
+ ,
+ buttonProps: { a11yText: "Clear", onClick },
+ }}
+ />,
+ );
+
+ const button = screen.getByRole("button", { name: "Clear" });
+ await expect.element(button).toBeDisabled();
+ button
+ .element()
+ .dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("refs", () => {
+ it("passes the ref to the input", async () => {
+ const ref = createRef();
+
+ await render();
+
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
+ expect(ref.current?.getAttribute("role")).toBe("combobox");
+ });
+ });
+
+ describe("listbox layout", () => {
+ it("makes the open listbox at least as wide as the combobox", async () => {
+ const screen = await render(
+
+
+
,
+ );
+ const input = screen.getByRole("combobox");
+
+ await user.click(input);
+
+ const listbox = screen.getByRole("listbox").element();
+ const wrapper = screen.container.querySelector(".combobox");
+ expect(listbox.getBoundingClientRect().width).toBeGreaterThanOrEqual(
+ (wrapper?.getBoundingClientRect().width ?? 0) - 1,
+ );
+ });
+ });
+});
diff --git a/packages/evo-react/src/combobox/test/test.server.tsx b/packages/evo-react/src/combobox/test/test.server.tsx
new file mode 100644
index 00000000000..cc594faac33
--- /dev/null
+++ b/packages/evo-react/src/combobox/test/test.server.tsx
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import { renderToString } from "react-dom/server";
+import { EvoIconClose12 } from "../../icon/icons/close-12";
+import { EvoCombobox } from "../combobox";
+import { EvoComboboxOption } from "../combobox-option";
+
+function renderCombobox(props: Parameters[0] = {}) {
+ return renderToString(
+
+
+
+ ,
+ );
+}
+
+describe("EvoCombobox SSR", () => {
+ it("renders the default combobox", () => {
+ expect(renderCombobox()).toMatchSnapshot();
+ });
+
+ it("renders a fluid combobox with a floating label", () => {
+ expect(
+ renderCombobox({
+ defaultValue: "August Campaign",
+ floatingLabel: "Campaign",
+ fluid: true,
+ }),
+ ).toMatchSnapshot();
+ });
+
+ it("renders an open borderless combobox", () => {
+ expect(
+ renderCombobox({ borderless: true, defaultOpen: true }),
+ ).toMatchSnapshot();
+ });
+
+ it("renders a fixed-strategy listbox", () => {
+ expect(
+ renderCombobox({ defaultOpen: true, strategy: "fixed" }),
+ ).toMatchSnapshot();
+ });
+
+ it("renders a disabled combobox as closed", () => {
+ expect(
+ renderCombobox({ defaultOpen: true, disabled: true }),
+ ).toMatchSnapshot();
+ });
+
+ it("renders an actionable postfix", () => {
+ expect(
+ renderCombobox({
+ postfix: {
+ icon: ,
+ buttonProps: { a11yText: "Clear" },
+ },
+ }),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/evo-react/src/combobox/types.ts b/packages/evo-react/src/combobox/types.ts
new file mode 100644
index 00000000000..6fafd154bea
--- /dev/null
+++ b/packages/evo-react/src/combobox/types.ts
@@ -0,0 +1,89 @@
+import type { ComponentProps, CSSProperties, ReactNode } from "react";
+import type { Strategy } from "@floating-ui/react";
+import type { NativeIconButtonProps } from "../icon-button";
+
+/** How the combobox filters its options. */
+export type ComboboxAutocomplete = "list" | "none";
+
+/** How keyboard navigation previews the active option. */
+export type ComboboxListSelection = "automatic" | "manual";
+
+/** Floating UI positioning strategy used by the listbox. */
+export type ComboboxStrategy = Strategy;
+
+/** Optional icon rendered after the combobox input. */
+export type EvoComboboxPostfixProps = {
+ /** Icon displayed after the input. */
+ icon: ReactNode;
+ /** Makes the icon actionable using an EvoIconButton. */
+ buttonProps?: Omit;
+};
+
+export type EvoComboboxProps = Omit<
+ ComponentProps<"input">,
+ | "aria-activedescendant"
+ | "aria-autocomplete"
+ | "aria-controls"
+ | "aria-expanded"
+ | "aria-haspopup"
+ | "aria-owns"
+ | "autoComplete"
+ | "children"
+ | "className"
+ | "defaultValue"
+ | "onSelect"
+ | "prefix"
+ | "role"
+ | "style"
+ | "type"
+ | "value"
+> & {
+ /**
+ * Filters visible options to those whose text includes the current input
+ * value. Defaults to `"none"`.
+ */
+ autocomplete?: ComboboxAutocomplete;
+ /** Removes the input border. */
+ borderless?: boolean;
+ /** EvoComboboxOption children rendered in the component's listbox. */
+ children?: ReactNode;
+ /** Class name applied to the combobox wrapper. */
+ className?: string;
+ /** Initial open state for uncontrolled usage. Ignored when `open` is provided. */
+ defaultOpen?: boolean;
+ /** Initial input value for uncontrolled usage. Ignored when `value` is provided. */
+ defaultValue?: string;
+ /** Floating label text shown above the input when focused or filled. */
+ floatingLabel?: string;
+ /** Stretches the component to fill its container. */
+ fluid?: boolean;
+ /**
+ * Whether arrow-key navigation previews the highlighted option text in the
+ * input. Defaults to `"automatic"`.
+ */
+ listSelection?: ComboboxListSelection;
+ /** Called with the displayed input value after typing or option selection. */
+ onValueChange?: (value: string) => void;
+ /** Called when the listbox requests to open or close. */
+ onOpenChange?: (open: boolean) => void;
+ /** Controlled listbox visibility. Manage changes with `onOpenChange`. */
+ open?: boolean;
+ /** Optional icon or icon button rendered after the input. */
+ postfix?: EvoComboboxPostfixProps;
+ /** Listbox positioning strategy. Defaults to `"absolute"`. */
+ strategy?: ComboboxStrategy;
+ /** Style applied to the combobox wrapper. */
+ style?: CSSProperties;
+ /** Controlled displayed input value. */
+ value?: string;
+};
+
+export type EvoComboboxOptionProps = Omit<
+ ComponentProps<"div">,
+ "aria-selected" | "children" | "id" | "role" | "tabIndex"
+> & {
+ /** Always shown during list autocomplete, even when its text does not match. */
+ sticky?: boolean;
+ /** Display text shown by the option and written into the input when selected. */
+ text: string;
+};
diff --git a/packages/evo-react/src/utils/use-active-descendant.ts b/packages/evo-react/src/utils/use-active-descendant.ts
new file mode 100644
index 00000000000..b39025bb21b
--- /dev/null
+++ b/packages/evo-react/src/utils/use-active-descendant.ts
@@ -0,0 +1,204 @@
+import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
+import type { RefObject } from "react";
+
+export type ActiveDescendantItem = {
+ key: Key;
+ id: string;
+ ref: RefObject;
+ data: Data;
+};
+
+export type ActiveDescendant = {
+ activeKey: Key | null;
+ registerItem: (item: ActiveDescendantItem) => () => void;
+ getActiveItem: () => ActiveDescendantItem | undefined;
+ activateNext: () => ActiveDescendantItem | undefined;
+ activatePrevious: () => ActiveDescendantItem | undefined;
+ reset: () => void;
+};
+
+type UseActiveDescendantOptions = {
+ containerRef: RefObject;
+ shouldWrap?: boolean;
+ scrollIntoView?: ScrollIntoViewOptions;
+};
+
+type UseActiveDescendantItemOptions = {
+ activeDescendant: ActiveDescendant;
+ item: ActiveDescendantItem;
+ enabled?: boolean;
+};
+
+function compareDomOrder(
+ first: ActiveDescendantItem,
+ second: ActiveDescendantItem,
+) {
+ const firstElement = first.ref.current;
+ const secondElement = second.ref.current;
+
+ if (!firstElement || !secondElement || firstElement === secondElement) {
+ return 0;
+ }
+
+ return firstElement.compareDocumentPosition(secondElement) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ? -1
+ : 1;
+}
+
+export function useActiveDescendant({
+ containerRef,
+ scrollIntoView,
+ shouldWrap = true,
+}: UseActiveDescendantOptions): ActiveDescendant {
+ const itemsRef = useRef(new Map>());
+ const [activeKey, setActiveKeyState] = useState(null);
+ const activeKeyRef = useRef(null);
+ activeKeyRef.current = activeKey;
+
+ const getItems = useCallback(() => {
+ const container = containerRef.current;
+
+ return [...itemsRef.current.values()]
+ .filter(({ ref }) => {
+ const element = ref.current;
+ return Boolean(element && container?.contains(element));
+ })
+ .sort(compareDomOrder);
+ }, [containerRef]);
+
+ const getActiveItem = useCallback(() => {
+ const key = activeKeyRef.current;
+ if (key === null) {
+ return undefined;
+ }
+
+ return getItems().find((item) => item.key === key);
+ }, [getItems]);
+
+ const activateKey = useCallback(
+ (key: Key | null) => {
+ if (key === null) {
+ activeKeyRef.current = null;
+ setActiveKeyState(null);
+ return;
+ }
+
+ const item = itemsRef.current.get(key);
+ if (!item || !item.ref.current) {
+ return;
+ }
+
+ activeKeyRef.current = key;
+ setActiveKeyState(key);
+ if (scrollIntoView) {
+ item.ref.current.scrollIntoView?.(scrollIntoView);
+ }
+ },
+ [scrollIntoView],
+ );
+
+ const reset = useCallback(() => {
+ activateKey(null);
+ }, [activateKey]);
+
+ const registerItem = useCallback(
+ (item: ActiveDescendantItem) => {
+ const key = item.key;
+ itemsRef.current.set(key, item);
+
+ return () => {
+ if (itemsRef.current.get(key) !== item) {
+ return;
+ }
+
+ itemsRef.current.delete(key);
+ if (activeKeyRef.current === key) {
+ activateKey(null);
+ }
+ };
+ },
+ [activateKey],
+ );
+
+ const move = useCallback(
+ (direction: 1 | -1) => {
+ const items = getItems();
+ if (items.length === 0) {
+ return undefined;
+ }
+
+ const currentIndex = items.findIndex(
+ ({ key }) => key === activeKeyRef.current,
+ );
+ let nextIndex: number | null;
+
+ if (currentIndex === -1) {
+ nextIndex = direction === 1 ? 0 : items.length - 1;
+ } else {
+ const rawIndex = currentIndex + direction;
+ if (rawIndex < 0 || rawIndex >= items.length) {
+ nextIndex = shouldWrap ? null : currentIndex;
+ } else {
+ nextIndex = rawIndex;
+ }
+ }
+
+ if (nextIndex === null) {
+ activateKey(null);
+ return undefined;
+ }
+
+ const nextItem = items[nextIndex];
+ activateKey(nextItem.key);
+ return nextItem;
+ },
+ [activateKey, getItems, shouldWrap],
+ );
+
+ const activateNext = useCallback(() => move(1), [move]);
+ const activatePrevious = useCallback(() => move(-1), [move]);
+
+ return useMemo(
+ () => ({
+ activeKey,
+ registerItem,
+ getActiveItem,
+ activateNext,
+ activatePrevious,
+ reset,
+ }),
+ [
+ activeKey,
+ activateNext,
+ activatePrevious,
+ getActiveItem,
+ registerItem,
+ reset,
+ ],
+ );
+}
+
+export function useActiveDescendantItem({
+ activeDescendant,
+ item,
+ enabled = true,
+}: UseActiveDescendantItemOptions): { isActive: boolean } {
+ const itemRef = useRef(item);
+ itemRef.current.key = item.key;
+ itemRef.current.id = item.id;
+ itemRef.current.ref = item.ref;
+ itemRef.current.data = item.data;
+
+ useLayoutEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ return activeDescendant.registerItem(itemRef.current);
+ }, [activeDescendant.registerItem, enabled]);
+
+ return {
+ isActive: enabled && activeDescendant.activeKey === item.key,
+ };
+}
diff --git a/packages/evo-react/src/utils/use-expander.ts b/packages/evo-react/src/utils/use-expander.ts
index e849d093a37..212d0511afe 100644
--- a/packages/evo-react/src/utils/use-expander.ts
+++ b/packages/evo-react/src/utils/use-expander.ts
@@ -8,7 +8,7 @@ import {
useFloating,
} from "@floating-ui/react";
import type { Middleware, Placement, Strategy } from "@floating-ui/react";
-import { useCallback, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
type UseExpanderOptions = {
@@ -21,6 +21,8 @@ type UseExpanderOptions = {
flip?: boolean;
shift?: boolean;
inline?: boolean;
+ /** Closes uncontrolled state when the owning component is disabled. */
+ resetOnDisabled?: boolean;
};
export function useExpander({
@@ -33,12 +35,19 @@ export function useExpander({
flip = true,
shift = true,
inline = true,
+ resetOnDisabled = false,
}: UseExpanderOptions = {}) {
const isControlled = open !== undefined;
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const currentOpen = isControlled ? open : uncontrolledOpen;
const arrowRef = useRef(null);
+ useEffect(() => {
+ if (resetOnDisabled && !isControlled) {
+ setUncontrolledOpen(false);
+ }
+ }, [isControlled, resetOnDisabled]);
+
const setOpen = useCallback(
(nextOpen: boolean) => {
if (!isControlled) {