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
6 changes: 6 additions & 0 deletions .changeset/add-evo-fake-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ebay/skin": patch
"@evo-web/react": patch
---

Add EvoFakeMenu component.
Comment thread
HenriqueLimas marked this conversation as resolved.
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 @@ -72,6 +72,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-cta-button`: [evo-cta-button.md](components/evo-cta-button.md)
- `ebay-details`: [evo-details.md](components/evo-details.md)
- `ebay-fake-menu`: [evo-fake-menu.md](components/evo-fake-menu.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-lightbox-dialog`: [evo-dialog.md](components/evo-dialog.md)
Expand Down
68 changes: 68 additions & 0 deletions .claude/skills/evo-app-migrate-react/components/evo-fake-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# ebay-fake-menu → evo-fake-menu

EvoFakeMenu uses the same explicit compound-component API as EvoMenu. Add `EvoFakeMenuItems` around all items, badges, and separators.

**Before:**

```tsx
import {
EbayFakeMenu,
EbayFakeMenuItem,
EbayFakeMenuSeparator,
} from "@ebay/ui-core-react/ebay-fake-menu";

<EbayFakeMenu
itemMatchesUrl={false}
onSelect={(_, { index }) => handleSelect(index)}
>
<EbayFakeMenuItem
href="/messages"
current
badgeNumber={5}
badgeAriaLabel="5 unread messages"
>
Messages
</EbayFakeMenuItem>
<EbayFakeMenuSeparator />
</EbayFakeMenu>;
```

**After:**

```tsx
import {
EvoFakeMenu,
EvoFakeMenuItem,
EvoFakeMenuItemBadge,
EvoFakeMenuItems,
EvoFakeMenuSeparator,
} from "@evo-web/react/fake-menu";

<EvoFakeMenu>
<EvoFakeMenuItems>
<EvoFakeMenuItem
href="/messages"
current
itemMatchesUrl={false}
onClick={handleMessagesSelect}
>
Messages
<EvoFakeMenuItemBadge number={5} a11yText="5 unread messages" />
</EvoFakeMenuItem>
<EvoFakeMenuSeparator />
</EvoFakeMenuItems>
</EvoFakeMenu>;
```

## Additional changes

- Add the required `EvoFakeMenuItems` list wrapper.
- Move root `itemMatchesUrl` to each applicable `EvoFakeMenuItem`.
- Replace parent `onSelect` with native `onClick` handlers on individual items; callbacks no longer receive child indexes.
- Move `onKeyDown` to `EvoFakeMenuItems` or an individual item. It now receives only the native keyboard event.
- Replace `badgeNumber` and `badgeAriaLabel` with `EvoFakeMenuItemBadge`. Rename the accessible label to `a11yText`.
- Replace `forwardedRef` with the native React 19 `ref`. The root ref now targets a `<span>` instead of a `<div>`; `EvoFakeMenuItems` accepts a list ref.
- Button item `type` now supports both `"button"` and `"submit"`.
- Disabled links no longer retain `href`; they receive `aria-disabled="true"`.
- Anchor items accept `as` for framework link adapters, such as `as={({ href, ...rest }) => <Link {...rest} to={href} />}` for React Router.
- `classPrefix`, `reverse`, `fixed`, and `fixWidth` are now available to match evo-marko embedding behavior.
5 changes: 5 additions & 0 deletions packages/evo-react/src/fake-menu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# EvoFakeMenu

## Documentation

[Storybook](https://opensource.ebay.com/evo-web/react/?path=/docs/building-blocks-evo-fake-menu--documentation)
32 changes: 32 additions & 0 deletions packages/evo-react/src/fake-menu/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createContext, use, useMemo } from "react";
import type { ReactNode } from "react";

type FakeMenuContextValue = {
baseClass: string;
};

const FakeMenuContext = createContext<FakeMenuContextValue | undefined>(
undefined,
);

export function useFakeMenuContext() {
const context = use(FakeMenuContext);
if (!context) {
throw new Error(
"Fake menu components must be used within an EvoFakeMenu component",
);
}
return context;
}

type FakeMenuProviderProps = FakeMenuContextValue & {
children?: ReactNode;
};

export function FakeMenuProvider({
baseClass,
children,
}: FakeMenuProviderProps) {
const value = useMemo(() => ({ baseClass }), [baseClass]);
return <FakeMenuContext value={value}>{children}</FakeMenuContext>;
}
6 changes: 6 additions & 0 deletions packages/evo-react/src/fake-menu/fake-menu-item-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { EvoBadge } from "../badge/badge";
import type { EvoFakeMenuItemBadgeProps } from "./types";

export function EvoFakeMenuItemBadge(props: EvoFakeMenuItemBadgeProps) {
return <EvoBadge {...props} type="menu" />;
}
59 changes: 59 additions & 0 deletions packages/evo-react/src/fake-menu/fake-menu-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ComponentProps } from "react";
import classNames from "classnames";
import { EvoIconTick16 } from "../icon/icons/tick-16";
import { useFakeMenuContext } from "./context";
import type { EvoFakeMenuItemProps } from "./types";

export function EvoFakeMenuItem({
className,
current = false,
itemMatchesUrl = true,
disabled = false,
type,
href,
as: _as,
children,
...rest
}: EvoFakeMenuItemProps) {
const { baseClass } = useFakeMenuContext();
const ariaCurrent = current ? (itemMatchesUrl ? "page" : "true") : undefined;
const itemClassName = classNames(`${baseClass}__item`, className);
Comment thread
HenriqueLimas marked this conversation as resolved.
const content = (
<>
<span>{children}</span>
<EvoIconTick16 />
</>
);

if (type === "button" || type === "submit") {
return (
<li>
<button
{...(rest as ComponentProps<"button">)}
type={type}
disabled={disabled}
aria-current={ariaCurrent}
className={itemClassName}
>
{content}
</button>
</li>
);
}

const Component = _as ?? "a";

return (
<li>
<Component
{...(rest as ComponentProps<"a">)}
href={disabled ? undefined : href}
aria-disabled={disabled ? "true" : undefined}
aria-current={ariaCurrent}
className={itemClassName}
>
Comment on lines +48 to +54
{content}
</Component>
</li>
);
}
18 changes: 18 additions & 0 deletions packages/evo-react/src/fake-menu/fake-menu-items.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import classNames from "classnames";
import type { EvoFakeMenuItemsProps } from "./types";

export function EvoFakeMenuItems({
className,
children,
...rest
}: EvoFakeMenuItemsProps) {
return (
<ul
{...rest}
className={classNames("fake-menu__items", className)}
tabIndex={-1}
>
{children}
</ul>
);
}
19 changes: 19 additions & 0 deletions packages/evo-react/src/fake-menu/fake-menu-separator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import classNames from "classnames";
import { useFakeMenuContext } from "./context";
import type { EvoFakeMenuSeparatorProps } from "./types";

export function EvoFakeMenuSeparator({
className,
...rest
}: EvoFakeMenuSeparatorProps) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: EvoFakeMenuSeparatorProps omits role from the native <hr> props (implying the component controls it), but no role="separator" is ever set here — the evo-marko sibling sets it explicitly (<hr class=... role="separator">). Native <hr> does default to an implicit separator role in most browsers/AT, so this likely isn't a real regression today, but consider setting it explicitly to match the sibling implementation and the apparent intent behind omitting role from the prop type.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a jsx-a11y/no-redundant-roles in eslint that fails if we add role="separator"

const { baseClass } = useFakeMenuContext();

return (
<li>
<hr
{...rest}
className={classNames(`${baseClass}__separator`, className)}
/>
</li>
);
}
138 changes: 138 additions & 0 deletions packages/evo-react/src/fake-menu/fake-menu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { EvoFakeMenu } from "./fake-menu";
import { EvoFakeMenuItem } from "./fake-menu-item";
import { EvoFakeMenuItemBadge } from "./fake-menu-item-badge";
import { EvoFakeMenuItems } from "./fake-menu-items";
import { EvoFakeMenuSeparator } from "./fake-menu-separator";

function Link({
to,
children,
...rest
}: ComponentProps<"a"> & { to?: string }) {
return (
<a
data-custom-link="true"
{...rest}
href={to}
onClick={(event) => event.preventDefault()}
>
{children}
</a>
);
}

const meta: Meta<typeof EvoFakeMenu> = {
title: "building blocks/evo-fake-menu",
component: EvoFakeMenu,
subcomponents: {
EvoFakeMenuItems,
EvoFakeMenuItem,
EvoFakeMenuItemBadge,
EvoFakeMenuSeparator,
},
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `
A compound navigation menu containing links, buttons, badges, and separators.

## Usage

\`\`\`tsx
import {
EvoFakeMenu,
EvoFakeMenuItem,
EvoFakeMenuItems,
} from "@evo-web/react/fake-menu";
\`\`\`
`,
},
},
},
argTypes: {
classPrefix: {
control: "text",
},
reverse: {
control: "boolean",
},
fixed: {
control: "boolean",
},
fixWidth: {
control: "boolean",
},
},
args: {
reverse: false,
fixed: false,
fixWidth: false,
},
render(args) {
return (
<EvoFakeMenu {...args}>
<EvoFakeMenuItems>
<EvoFakeMenuItem href="#" onClick={(event) => event.preventDefault()}>
Messages
<EvoFakeMenuItemBadge number={5} a11yText="5 unread messages" />
</EvoFakeMenuItem>
<EvoFakeMenuItem
href="#"
current
onClick={(event) => event.preventDefault()}
>
Current page
</EvoFakeMenuItem>
<EvoFakeMenuSeparator />
<EvoFakeMenuItem type="button">Button item</EvoFakeMenuItem>
<EvoFakeMenuItem href="#" disabled>
Disabled link
</EvoFakeMenuItem>
</EvoFakeMenuItems>
</EvoFakeMenu>
);
},
};

export default meta;
type Story = StoryObj<typeof EvoFakeMenu>;

export const Default: Story = {};

export const WithCustomLinkComponent: Story = {
render: (args) => (
<EvoFakeMenu {...args}>
<EvoFakeMenuItems>
<EvoFakeMenuItem
href="/account"
as={({ href = "", ...rest }) => <Link {...rest} to={href} />}
>
Account
</EvoFakeMenuItem>
</EvoFakeMenuItems>
</EvoFakeMenu>
),
parameters: {
docs: {
description: {
story: `
Pass a custom component through the \`as\` prop to replace the native anchor. This example adapts React Router's \`Link\`, which uses \`to\` instead of \`href\`.

\`\`\`tsx
import { Link } from "react-router";

<EvoFakeMenuItem
href="/account"
as={({ href = "", ...rest }) => <Link {...rest} to={href} />}
>
Account
</EvoFakeMenuItem>
\`\`\`
`,
},
},
},
};
Loading
Loading