Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions .changeset/add-evo-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@ebay/skin": patch
"@evo-web/marko": patch
"@evo-web/react": patch
---

Add EvoMenu to React and align its accessible naming API with Marko using `a11yText` and `a11yLabelId`.
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 @@ -74,6 +74,7 @@ When migrating a listed component, read the linked file completely and apply the
| `ebay-confirm-dialog` | [evo-confirm-dialog.md](components/evo-confirm-dialog.md) |
| `ebay-filter-chip` | [evo-filter-chip.md](components/evo-filter-chip.md) |
| `ebay-icon-button` | [evo-icon-button.md](components/evo-icon-button.md) |
| `ebay-menu` | [evo-menu.md](components/evo-menu.md) |

---

Expand Down
128 changes: 128 additions & 0 deletions .claude/skills/evo-app-migrate-react/components/evo-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# ebay-menu → evo-menu

EvoMenu uses an explicit compound-component API. Wrap all navigable items in `EvoMenuItems`, and use the item and group component that matches each item's behavior.

## Command menu, badge, separator, and footer

**Before:**

```tsx
import {
EbayMenu,
EbayMenuItem,
EbayMenuSeparator,
} from "@ebay/ui-core-react/ebay-menu";

<EbayMenu onSelect={(_, { index }) => handleSelect(index)}>
<EbayMenuItem badgeNumber={5} badgeAriaLabel="5 unread messages">
Messages
</EbayMenuItem>
<EbayMenuSeparator />
<EbayMenuItem>Settings</EbayMenuItem>
</EbayMenu>;
```

**After:**

```tsx
import { EvoButton } from "@evo-web/react/button";
import {
EvoMenu,
EvoMenuFooter,
EvoMenuItem,
EvoMenuItemBadge,
EvoMenuItems,
EvoMenuSeparator,
} from "@evo-web/react/menu";

<EvoMenu>
<EvoMenuItems a11yText="Actions">
<EvoMenuItem onSelect={handleMessagesSelect}>
Messages
<EvoMenuItemBadge number={5} a11yText="5 unread messages" />
</EvoMenuItem>
<EvoMenuSeparator />
<EvoMenuItem onSelect={handleSettingsSelect}>Settings</EvoMenuItem>
</EvoMenuItems>
<EvoMenuFooter>
<EvoButton priority="tertiary">Apply</EvoButton>
</EvoMenuFooter>
</EvoMenu>;
```

`EvoMenuFooter` is optional. When footer content is needed, place it next to `EvoMenuItems` and use `EvoButton` directly instead of a menu-specific footer button.

## Radio menu

**Before:**

```tsx
<EbayMenu
type="radio"
checked={1}
onChange={(_, { index }) => handleSortChange(index)}
>
<EbayMenuItem value="price">Price</EbayMenuItem>
<EbayMenuItem value="distance">Distance</EbayMenuItem>
</EbayMenu>
```

**After:**

```tsx
<EvoMenu>
<EvoMenuItems a11yText="Sort by">
<EvoMenuRadioGroup
defaultSelected="distance"
onSelectedChange={handleSortChange}
>
<EvoMenuRadioItem value="price">Price</EvoMenuRadioItem>
<EvoMenuRadioItem value="distance">Distance</EvoMenuRadioItem>
</EvoMenuRadioGroup>
</EvoMenuItems>
</EvoMenu>
```

Use `selected` instead of `defaultSelected` when the radio group is controlled. Selection callbacks now receive the selected item value rather than its child index.

## Checkbox menu

**Before:**

```tsx
<EbayMenu
type="checkbox"
onChange={(_, { checkedValues }) => handleFilterChange(checkedValues)}
>
<EbayMenuItem value="shipping" checked>
Free shipping
</EbayMenuItem>
<EbayMenuItem value="returns">Free returns</EbayMenuItem>
</EbayMenu>
```

**After:**

```tsx
<EvoMenu>
<EvoMenuItems a11yText="Filters">
<EvoMenuCheckboxGroup
defaultSelected={["shipping"]}
onSelectedChange={handleFilterChange}
>
<EvoMenuCheckboxItem value="shipping">Free shipping</EvoMenuCheckboxItem>
<EvoMenuCheckboxItem value="returns">Free returns</EvoMenuCheckboxItem>
</EvoMenuCheckboxGroup>
</EvoMenuItems>
</EvoMenu>
```

Use `selected` instead of `defaultSelected` when the checkbox group is controlled. Radio and checkbox item `value` props are required; selection never falls back to child indexes.

## Additional changes

- Use `a11yText` on `EvoMenuItems` for an accessible name, or use `a11yLabelId` when an external element labels the menu. Do not use both.
- Move plain-menu `onSelect` to each `EvoMenuItem`; move parent `onClick` handlers to the applicable items.
- Move `onKeyDown` to `EvoMenuItems` when needed. It receives the native keyboard event without legacy index or checked callback data.
- Remove `baseEl`, `priority`, `autofocus`, `forwardedRef`, and `menuRef`. The root is a span, refs use native React 19 `ref`, and `EvoMenuItems` accepts its own ref.
- `reverse`, `fixed`, `fixWidth`, `classPrefix`, and native HTML attributes remain supported.
3 changes: 2 additions & 1 deletion packages/evo-marko/src/tags/evo-menu-button/index.marko
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import Button, { type Input as ButtonInput } from "<evo-button>";
import { type Input as MenuInput } from "<evo-menu>";
export interface Input<
Index extends number | string | (number | string)[],
> extends Omit<MenuInput<Index>, "variant"> {
> extends Omit<MenuInput<Index>, "a11yLabelId" | "a11yText" | "variant">,
Pick<Marko.HTML.Span, "aria-label" | "aria-labelledby"> {
open?: boolean;
openChange?: (open: boolean) => void;
collapseOnSelect?: boolean;
Expand Down
14 changes: 9 additions & 5 deletions packages/evo-marko/src/tags/evo-menu/index.marko
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export interface Item extends Marko.HTML.Div {
}
export interface Input<
Index extends number | string | (number | string)[],
> extends Marko.HTML.Span {
> extends Omit<Marko.HTML.Span, "aria-label" | "aria-labelledby"> {
/** Localized accessible name for the menu. Cannot be used with `a11yLabelId`. */
a11yText?: string;
/** ID of the element that labels the menu. Cannot be used with `a11yText`. */
a11yLabelId?: string;
selected?: Index;
selectedChange?: (selected: Index) => void;
item?: Marko.AttrTag<Item>;
Expand All @@ -23,6 +27,8 @@ export interface Input<
<const/{
class: inputClass,
classPrefix,
a11yText,
a11yLabelId,
selected: inputIndex,
selectedChange,
reverse,
Expand All @@ -32,8 +38,6 @@ export interface Input<
typeaheadTimeoutLength,
item: items,
variant,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
...htmlInput
}=input>
<const/isRadio=typeof inputIndex === "number" || typeof inputIndex === "string">
Expand All @@ -58,8 +62,8 @@ export interface Input<
<div
role="menu"
class=`${baseClass}__items`
aria-label=ariaLabel
aria-labelledby=ariaLabelledby
aria-label=a11yText
aria-labelledby=a11yLabelId
id=menu>
<const/$items: Iterable<HTMLElement>=$item> // TODO: remove when Marko TS works
<evo-roving-tabindex/rovTabindex
Expand Down
12 changes: 12 additions & 0 deletions packages/evo-marko/src/tags/evo-menu/menu.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export default {
},

argTypes: {
a11yText: {
type: "string",
control: "text",
description:
"Localized accessible name for the menu. Cannot be used with `a11yLabelId`.",
},
a11yLabelId: {
type: "string",
control: "text",
description:
"ID of the element that labels the menu. Cannot be used with `a11yText`.",
},
selected: {
controllable: true,
description:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,12 @@ exports[`menu > renders basic version 1`] = `
</span>"
`;

exports[`menu > renders with aria-label 1`] = `
exports[`menu > renders with a11yLabelId 1`] = `
"<span
arialabel="test"
class="menu"
>
<div
aria-labelledby="test"
class="menu__items"
id="GENERATED-0"
role="menu"
Expand Down Expand Up @@ -321,12 +321,12 @@ exports[`menu > renders with aria-label 1`] = `
</span>"
`;

exports[`menu > renders with aria-labelledby 1`] = `
exports[`menu > renders with a11yText 1`] = `
"<span
arialabelledby="test"
class="menu"
>
<div
aria-label="test"
class="menu__items"
id="GENERATED-0"
role="menu"
Expand Down
8 changes: 4 additions & 4 deletions packages/evo-marko/src/tags/evo-menu/test/test.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ describe("menu", () => {
await snapshotHTML(Default);
});

it("renders with aria-label", async () => {
await snapshotHTML(Default, { ariaLabel: "test" });
it("renders with a11yText", async () => {
await snapshotHTML(Default, { a11yText: "test" });
});

it("renders with aria-labelledby", async () => {
await snapshotHTML(Default, { ariaLabelledBy: "test" });
it("renders with a11yLabelId", async () => {
await snapshotHTML(Default, { a11yLabelId: "test" });
});

it("renders with reverse=true", async () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/evo-react/src/menu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# EvoMenu

## Documentation

[Storybook](https://opensource.ebay.com/evo-web/react/?path=/docs/building-blocks-evo-menu--documentation)
Loading
Loading