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/calm-comboboxes-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evo-web/react": patch
---

Add EvoCombobox component.
1 change: 1 addition & 0 deletions .claude/skills/evo-app-migrate-react/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ When migrating a listed component, read the linked file completely and apply the
- `ebay-character-count`: [evo-character-count.md](components/evo-character-count.md)
- `ebay-checkbox`: [evo-checkbox.md](components/evo-checkbox.md)
- `ebay-chip`: [evo-chip.md](components/evo-chip.md)
- `ebay-combobox`: [evo-combobox.md](components/evo-combobox.md)
- `ebay-confirm-dialog`: [evo-confirm-dialog.md](components/evo-confirm-dialog.md)
- `ebay-cta-button`: [evo-cta-button.md](components/evo-cta-button.md)
- `ebay-details`: [evo-details.md](components/evo-details.md)
Expand Down
68 changes: 68 additions & 0 deletions .claude/skills/evo-app-migrate-react/components/evo-combobox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# ebay-combobox → evo-combobox

EvoCombobox is a textbox with text suggestions. It renders its input and listbox internally. Pass `EvoComboboxOption` components directly as children and replace `EbayComboboxButton` with the root `postfix` prop.

## Composition

**Before:**

```tsx
<EbayCombobox>
<EbayComboboxButton onClick={clear}>
<EbayIconClear16 />
</EbayComboboxButton>
<EbayComboboxOption text="August Campaign" value="august-campaign" />
</EbayCombobox>
```

**After:**

```tsx
<EvoCombobox
postfix={{
icon: <EvoIconClear16 />,
buttonProps: { a11yText: "Clear", onClick: clear },
}}
>
<EvoComboboxOption text="August Campaign" />
</EvoCombobox>
```

Options render their `text` and do not accept `children`. The new `sticky` prop keeps an option visible when `autocomplete="list"` filters other options.

## Value changes

Replace `onInputChange` with `onValueChange`. It receives the displayed string after typing or committed option selection.

```tsx
<EvoCombobox value={value} onValueChange={setValue}>
<EvoComboboxOption text="August Campaign" />
</EvoCombobox>
```

The legacy blur-based `onChange` behavior is removed; use native `onBlur` when needed. Native `onBlur` runs after Evo's internal focus-leave processing, so controlled consumers may restore a value in their blur handler without the wrapper overwriting it afterward.

EvoCombobox represents textbox text, not selected option identity. Text suggestions and free-form autocomplete can migrate to the text-only API. Legacy uses of option `value`, root `onSelect`, or separate ID and label state are entity selectors; entity selectors have no direct Evo replacement yet and require manual engineering review. Agents must not remove identity handling automatically.

## Open state

Rename open-state props and callbacks:

```diff
- expanded={expanded}
- onExpand={() => setExpanded(true)}
- onCollapse={() => setExpanded(false)}
+ open={open}
+ onOpenChange={setOpen}
```

Use `defaultOpen` for an initially open uncontrolled combobox. The new `strategy` prop accepts `"absolute"` or `"fixed"` and defaults to `"absolute"`.

## Removed props

- `dropdownRef`: use the native React 19 input `ref`; listbox refs are internal.
- `opaqueLabel`: not supported by Evo Marko or Evo React input conventions.
- `onFloatingLabelInit`: the floating label no longer has an imperative initialization phase.
- `onSelect`: use `onValueChange` for committed textbox value changes.
- Option `value`: Text suggestion migrations should use the option's `text`; legacy machine-readable values identify an entity selector and require manual engineering review.
- Option `selected`: EvoCombobox does not manage committed option identity; do not remove legacy identity handling automatically.
5 changes: 5 additions & 0 deletions packages/evo-react/src/combobox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# EvoCombobox

## Documentation

[Storybook](https://opensource.ebay.com/evo-web/react/?path=/docs/form-input-evo-combobox--documentation)
100 changes: 100 additions & 0 deletions packages/evo-react/src/combobox/combobox-option.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useId } from "react";
import type { Ref, RefObject } from "react";
import classNames from "classnames";
import { useActiveDescendantItem } from "../utils/use-active-descendant";
import { useRefTee } from "../utils/use-ref-tee";
import { useComboboxContext } from "./context";
import type { EvoComboboxOptionProps } from "./types";

function matchesFilter(text: string, filterValue: string) {
return text.toLowerCase().includes(filterValue.trim().toLowerCase());
}

export function EvoComboboxOption({
className,
onClick,
onKeyDown,
onMouseDown,
ref,
sticky = false,
text,
...rest
}: EvoComboboxOptionProps) {
const generatedId = useId();
const {
activeDescendant,
autocomplete,
displayedValue,
filterValue,
selectOption,
disabled,
} = useComboboxContext();
const [optionRef, internalRef] = useRefTee<HTMLDivElement | null>(
ref as Ref<HTMLDivElement | null>,
null,
);
const hidden =
autocomplete === "list" && !sticky && !matchesFilter(text, filterValue);
const { isActive } = useActiveDescendantItem({
activeDescendant,
enabled: !hidden,
item: {
key: generatedId,
id: generatedId,
ref: internalRef as RefObject<HTMLDivElement | null>,
data: text,
},
});

if (hidden) {
return null;
}

return (
<div
{...rest}
id={generatedId}
ref={optionRef}
role="option"
tabIndex={-1}
aria-selected={text === displayedValue}
className={classNames(
"combobox__option",
isActive && "combobox__option--active",
className,
)}
onMouseDown={(event) => {
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}
</div>
);
}
133 changes: 133 additions & 0 deletions packages/evo-react/src/combobox/combobox.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof EvoCombobox> = {
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<typeof EvoCombobox>;

export const Default: Story = {
render: (args) => (
<EvoCombobox {...args}>
<EvoComboboxOption text="August Campaign" />
<EvoComboboxOption text="4th of July Sale (paused)" />
<EvoComboboxOption text="Basic Offer" />
<EvoComboboxOption text="Create campaign" sticky />
</EvoCombobox>
),
};

export const Controlled: Story = {
args: {
value: "August Campaign",
},
render: (args) => (
<EvoCombobox {...args}>
<EvoComboboxOption text="August Campaign" />
<EvoComboboxOption text="4th of July Sale (paused)" />
<EvoComboboxOption text="Basic Offer" />
<EvoComboboxOption text="Create campaign" sticky />
</EvoCombobox>
),
};

export const Postfix: Story = {
render: (args) => {
const [value, setValue] = useState("August Campaign");

return (
<EvoCombobox
{...args}
value={value}
onValueChange={setValue}
postfix={{
icon: <EvoIconClear16 />,
buttonProps: {
a11yText: "Clear",
onClick: () => setValue(""),
},
}}
>
<EvoComboboxOption text="August Campaign" />
<EvoComboboxOption text="4th of July Sale (paused)" />
<EvoComboboxOption text="Basic Offer" />
<EvoComboboxOption text="Create campaign" sticky />
</EvoCombobox>
);
},
};
Loading
Loading