diff --git a/.changeset/fake-tabs-add-panels-rename.md b/.changeset/fake-tabs-add-panels-rename.md
new file mode 100644
index 00000000000..cb0ce2e0f7d
--- /dev/null
+++ b/.changeset/fake-tabs-add-panels-rename.md
@@ -0,0 +1,6 @@
+---
+"@evo-web/marko": patch
+"@evo-web/react": patch
+---
+
+Add EvoFakeTabs, rename EvoTabPanelList to EvoTabPanels, and align React and Marko fake-tab link and disabled behavior.
diff --git a/.claude/skills/evo-app-migrate-react/SKILL.md b/.claude/skills/evo-app-migrate-react/SKILL.md
index 11a1c755b15..6852f8b8c07 100644
--- a/.claude/skills/evo-app-migrate-react/SKILL.md
+++ b/.claude/skills/evo-app-migrate-react/SKILL.md
@@ -74,6 +74,7 @@ When migrating a listed component, read the linked file completely and apply the
- `ebay-cta-button`: [evo-cta-button.md](components/evo-cta-button.md)
- `ebay-details`: [evo-details.md](components/evo-details.md)
- `ebay-eek`: [evo-eek.md](components/evo-eek.md)
+- `ebay-fake-tabs`: [evo-fake-tabs.md](components/evo-fake-tabs.md)
- `ebay-file-input`: [evo-file-input.md](components/evo-file-input.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)
diff --git a/.claude/skills/evo-app-migrate-react/components/evo-fake-tabs.md b/.claude/skills/evo-app-migrate-react/components/evo-fake-tabs.md
new file mode 100644
index 00000000000..70fa502c8a1
--- /dev/null
+++ b/.claude/skills/evo-app-migrate-react/components/evo-fake-tabs.md
@@ -0,0 +1,70 @@
+# ebay-fake-tabs → evo-fake-tabs
+
+This component has a **new id-based compound API**. The old positional `selectedIndex` and child-scanning (`filterByType`) approach is replaced by named sub-components and a required `selected` ID.
+
+Fake tabs are navigation links with `aria-current`, not an interactive ARIA tab widget. Each tab is an anchor pointing to a distinct URL. The selected tab reflects the current page.
+
+**Before:**
+
+```tsx
+import { EbayFakeTabs, EbayFakeTab } from "@ebay/ui-core-react/ebay-fake-tabs";
+
+
+ US
+ Germany
+ Germany Content
+ Some German content…
+ ;
+```
+
+**After:**
+
+```tsx
+import {
+ EvoFakeTabs,
+ EvoFakeTabList,
+ EvoFakeTab,
+ EvoFakeTabPanel,
+} from "@evo-web/react/fake-tabs";
+
+
+
+
+ US
+
+
+ Germany
+
+
+
+ Germany Content
+ Some German content…
+
+ ;
+```
+
+**Prop changes:**
+
+| ebayui-core-react | evo-react | Notes |
+| --------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| `selectedIndex` (number) | `selected` (string \| number \| null) | Assign each tab an `id`; pass that id as `selected`. Pass `null` when no tab is current. |
+| Flat `EbayFakeTab` children | `EvoFakeTabList` + `EvoFakeTab` | Each `EvoFakeTab` requires a unique `id` prop. |
+| Panel content as siblings | `EvoFakeTabPanel` | Wrap page content in `EvoFakeTabPanel`. |
+| `size="regular"\|"large"` | `size="regular"\|"large"` | Same values; defaults to `"regular"`. |
+| `tabMatchesCurrentUrl` | `tabMatchesCurrentUrl` | Same behavior; `true` → `aria-current="page"`, `false` → `aria-current="true"`. |
+| `href` on `EbayFakeTab` | Optional `href` on `EvoFakeTab` | Move `href` to the tab element. Omit it to render the tab disabled. |
+| No `as` support | `as` on `EvoFakeTab` | Pass a custom anchor component (e.g. ` ` from React Router) via `as`. It receives `href`. |
+
+### React Router / Next.js link adapter
+
+```tsx
+import { Link } from "react-router";
+
+function RouterTab({ href, ...props }: React.ComponentProps<"a">) {
+ return href === undefined ? null : ;
+}
+
+
+ US
+ ;
+```
diff --git a/.claude/skills/evo-app-migrate-react/components/evo-tabs.md b/.claude/skills/evo-app-migrate-react/components/evo-tabs.md
index 41b35ae96b4..b979d33d1c5 100644
--- a/.claude/skills/evo-app-migrate-react/components/evo-tabs.md
+++ b/.claude/skills/evo-app-migrate-react/components/evo-tabs.md
@@ -23,20 +23,20 @@ Keyboard activation remains `"auto"` by default, matching both legacy React and
Overview
-
+
Overview content
-
+
```
**Prop changes:**
-| ebayui-core-react | evo-react | Notes |
-| ----------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `selectedIndex` | `selected` / `defaultSelected` | Preserve each positional index as the matching numeric tab and panel id. Use `selected` for controlled state or `defaultSelected` for uncontrolled state. One is required. |
-| `onSelect({ selectedIndex })` | `onSelectedChange(selected)` | Receives the selected string or number directly. |
-| Flat `EbayTab` children | `EvoTabList` containing `EvoTab` | Every tab requires a unique `id`. |
-| Flat `EbayTabPanel` children | `EvoTabPanelList` containing `EvoTabPanel` | Every panel requires the matching tab `id`. |
-| `size` | `size` | Same values: `"medium" \| "large"`. |
-| `activation` | `activation` | Same values: `"auto" \| "manual"`; defaults to `"auto"`. |
-| `disabled` on `EbayTab` | `disabled` on `EvoTab` | No behavior change. |
+| ebayui-core-react | evo-react | Notes |
+| ----------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `selectedIndex` | `selected` / `defaultSelected` | Preserve each positional index as the matching numeric tab and panel id. Use `selected` for controlled state or `defaultSelected` for uncontrolled state. One is required. |
+| `onSelect({ selectedIndex })` | `onSelectedChange(selected)` | Receives the selected string or number directly. |
+| Flat `EbayTab` children | `EvoTabList` containing `EvoTab` | Every tab requires a unique `id`. |
+| Flat `EbayTabPanel` children | `EvoTabPanels` containing `EvoTabPanel` | Every panel requires the matching tab `id`. |
+| `size` | `size` | Same values: `"medium" \| "large"`. |
+| `activation` | `activation` | Same values: `"auto" \| "manual"`; defaults to `"auto"`. |
+| `disabled` on `EbayTab` | `disabled` on `EvoTab` | No behavior change. |
diff --git a/packages/evo-marko/src/tags/evo-fake-tabs/examples/disabled.marko b/packages/evo-marko/src/tags/evo-fake-tabs/examples/disabled.marko
new file mode 100644
index 00000000000..2cb121e418c
--- /dev/null
+++ b/packages/evo-marko/src/tags/evo-fake-tabs/examples/disabled.marko
@@ -0,0 +1,14 @@
+
+ <@tab href="https://www.ebay.com/">
+ Tab 1
+ @tab>
+ <@tab>
+ Tab 2
+ @tab>
+ <@tab href="https://www.ebay.com/">
+ Tab 3
+ @tab>
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla ornare, quam at lacinia pretium, lacus urna luctus nisi, eget molestie massa tortor id lacus. Aenean ac fringilla lacus. Fusce vel dui ex. Vivamus luctus egestas nulla, non hendrerit purus luctus at. Maecenas vel diam enim. Pellentesque quam neque, porttitor tincidunt vestibulum at, dapibus sit amet tortor.
+
+
diff --git a/packages/evo-marko/src/tags/evo-fake-tabs/fake-tabs.stories.ts b/packages/evo-marko/src/tags/evo-fake-tabs/fake-tabs.stories.ts
index 119e657b920..8b6c9f6f487 100644
--- a/packages/evo-marko/src/tags/evo-fake-tabs/fake-tabs.stories.ts
+++ b/packages/evo-marko/src/tags/evo-fake-tabs/fake-tabs.stories.ts
@@ -6,6 +6,8 @@ import DefaultTemplate from "./examples/default.marko";
import DefaultTemplateCode from "./examples/default.marko?raw";
import NoPanelTemplate from "./examples/no-panel-content.marko";
import NoPanelTemplateCode from "./examples/no-panel-content.marko?raw";
+import DisabledTemplate from "./examples/disabled.marko";
+import DisabledTemplateCode from "./examples/disabled.marko?raw";
export default {
title: "navigation & disclosure/evo-fake-tabs",
@@ -56,3 +58,8 @@ export const NoPanel = buildExtensionTemplate(
NoPanelTemplate,
NoPanelTemplateCode,
);
+
+export const Disabled = buildExtensionTemplate(
+ DisabledTemplate,
+ DisabledTemplateCode,
+);
diff --git a/packages/evo-marko/src/tags/evo-fake-tabs/index.marko b/packages/evo-marko/src/tags/evo-fake-tabs/index.marko
index 295dc066c83..497233bc0b6 100644
--- a/packages/evo-marko/src/tags/evo-fake-tabs/index.marko
+++ b/packages/evo-marko/src/tags/evo-fake-tabs/index.marko
@@ -27,7 +27,9 @@ export interface Input extends Marko.HTML.Div {
+ href=href
+ aria-current=href !== undefined && selected === i && tabAriaCurrent
+ aria-disabled=href === undefined && "true"/>
diff --git a/packages/evo-marko/src/tags/evo-fake-tabs/test/__snapshots__/test.server.ts.snap b/packages/evo-marko/src/tags/evo-fake-tabs/test/__snapshots__/test.server.ts.snap
index 0e46371a9d6..ab240c9f5de 100644
--- a/packages/evo-marko/src/tags/evo-fake-tabs/test/__snapshots__/test.server.ts.snap
+++ b/packages/evo-marko/src/tags/evo-fake-tabs/test/__snapshots__/test.server.ts.snap
@@ -10,7 +10,9 @@ exports[`fake-tabs > renders default 1`] = `
-
+
Tab 1
@@ -19,6 +21,7 @@ exports[`fake-tabs > renders default 1`] = `
>
Tab 2
@@ -26,7 +29,64 @@ exports[`fake-tabs > renders default 1`] = `
-
+
+ Tab 3
+
+
+
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla ornare, quam at lacinia pretium, lacus urna luctus nisi, eget molestie massa tortor id lacus. Aenean ac fringilla lacus. Fusce vel dui ex. Vivamus luctus egestas nulla, non hendrerit purus luctus at. Maecenas vel diam enim. Pellentesque quam neque, porttitor tincidunt vestibulum at, dapibus sit amet tortor.
+
+
+
+
+
+"
+`;
+
+exports[`fake-tabs > renders disabled tab without href or aria-current 1`] = `
+"
+
+
+
+ Tab 1
+
+
+
+
+ Tab 2
+
+
+
+
Tab 3
@@ -63,6 +123,7 @@ exports[`fake-tabs > renders no panel 1`] = `
>
Tab 1
@@ -70,14 +131,18 @@ exports[`fake-tabs > renders no panel 1`] = `
-
+
Tab 2
-
+
Tab 3
@@ -108,21 +173,27 @@ exports[`fake-tabs > renders with no selected index 1`] = `
-
+
Tab 1
-
+
Tab 2
-
+
Tab 3
@@ -157,14 +228,18 @@ exports[`fake-tabs > renders with other selected index 1`] = `
-
+
Tab 1
-
+
Tab 2
@@ -173,6 +248,7 @@ exports[`fake-tabs > renders with other selected index 1`] = `
>
Tab 3
@@ -208,7 +284,9 @@ exports[`fake-tabs > renders with tabMatchesCurrentUrl=false 1`] = `
-
+
Tab 1
@@ -217,6 +295,7 @@ exports[`fake-tabs > renders with tabMatchesCurrentUrl=false 1`] = `
>
Tab 2
@@ -224,7 +303,9 @@ exports[`fake-tabs > renders with tabMatchesCurrentUrl=false 1`] = `
-
+
Tab 3
diff --git a/packages/evo-marko/src/tags/evo-fake-tabs/test/test.server.ts b/packages/evo-marko/src/tags/evo-fake-tabs/test/test.server.ts
index cd5e91bffbd..c9c7c4f17ff 100644
--- a/packages/evo-marko/src/tags/evo-fake-tabs/test/test.server.ts
+++ b/packages/evo-marko/src/tags/evo-fake-tabs/test/test.server.ts
@@ -2,7 +2,7 @@ import { describe, it } from "vitest";
import { composeStories } from "@storybook/marko";
import { snapshotHTML } from "../../../common/test-utils/snapshots";
import * as stories from "../fake-tabs.stories"; // import all stories from the stories file
-const { Default, NoPanel } = composeStories(stories);
+const { Default, Disabled, NoPanel } = composeStories(stories);
describe("fake-tabs", () => {
it("renders default", async () => {
@@ -13,15 +13,18 @@ describe("fake-tabs", () => {
await snapshotHTML(NoPanel);
});
+ it("renders disabled tab without href or aria-current", async () => {
+ await snapshotHTML(Disabled);
+ });
+
it("renders with tabMatchesCurrentUrl=false", async () => {
- await snapshotHTML(Default, {tabMatchesCurrentUrl: false});
+ await snapshotHTML(Default, { tabMatchesCurrentUrl: false });
});
it("renders with other selected index", async () => {
- await snapshotHTML(Default, {selected: 2});
+ await snapshotHTML(Default, { selected: 2 });
});
it("renders with no selected index", async () => {
- await snapshotHTML(Default, {selected: -1});
+ await snapshotHTML(Default, { selected: -1 });
});
-
});
diff --git a/packages/evo-react/src/fake-tabs/README.md b/packages/evo-react/src/fake-tabs/README.md
new file mode 100644
index 00000000000..f978d9b6910
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/README.md
@@ -0,0 +1,5 @@
+# EvoFakeTabs
+
+## Documentation
+
+[Storybook](https://opensource.ebay.com/evo-web/react/?path=/docs/navigation-disclosure-evo-fake-tabs--documentation)
diff --git a/packages/evo-react/src/fake-tabs/context.tsx b/packages/evo-react/src/fake-tabs/context.tsx
new file mode 100644
index 00000000000..9bceb13c853
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/context.tsx
@@ -0,0 +1,36 @@
+import { createContext, use, useMemo } from "react";
+import type { ReactNode } from "react";
+import type { FakeTabId } from "./types";
+
+type FakeTabsContextValue = {
+ selected: FakeTabId | null;
+ ariaCurrent: "page" | "true";
+};
+
+const FakeTabsContext = createContext(
+ undefined,
+);
+
+export function useFakeTabsContext() {
+ const ctx = use(FakeTabsContext);
+ if (!ctx) {
+ throw new Error("EvoFakeTab must be used within an EvoFakeTabs component");
+ }
+ return ctx;
+}
+
+type FakeTabsProviderProps = FakeTabsContextValue & {
+ children?: ReactNode;
+};
+
+export function FakeTabsProvider({
+ selected,
+ ariaCurrent,
+ children,
+}: FakeTabsProviderProps) {
+ const value = useMemo(
+ () => ({ selected, ariaCurrent }),
+ [selected, ariaCurrent],
+ );
+ return {children} ;
+}
diff --git a/packages/evo-react/src/fake-tabs/fake-tab-list.tsx b/packages/evo-react/src/fake-tabs/fake-tab-list.tsx
new file mode 100644
index 00000000000..dfb7ce0dcf4
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/fake-tab-list.tsx
@@ -0,0 +1,14 @@
+import classNames from "classnames";
+import type { EvoFakeTabListProps } from "./types";
+
+export function EvoFakeTabList({
+ className,
+ children,
+ ...rest
+}: EvoFakeTabListProps) {
+ return (
+
+ );
+}
diff --git a/packages/evo-react/src/fake-tabs/fake-tab-panel.tsx b/packages/evo-react/src/fake-tabs/fake-tab-panel.tsx
new file mode 100644
index 00000000000..9473c0e9597
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/fake-tab-panel.tsx
@@ -0,0 +1,18 @@
+import classNames from "classnames";
+import type { EvoFakeTabPanelProps } from "./types";
+
+export function EvoFakeTabPanel({
+ className,
+ children,
+ ...rest
+}: EvoFakeTabPanelProps) {
+ return (
+
+ );
+}
diff --git a/packages/evo-react/src/fake-tabs/fake-tab.tsx b/packages/evo-react/src/fake-tabs/fake-tab.tsx
new file mode 100644
index 00000000000..d7e8780fe74
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/fake-tab.tsx
@@ -0,0 +1,32 @@
+import classNames from "classnames";
+import { useFakeTabsContext } from "./context";
+import type { EvoFakeTabProps } from "./types";
+
+export function EvoFakeTab({
+ id,
+ href,
+ as: Component,
+ className,
+ children,
+ onClick,
+ ...rest
+}: EvoFakeTabProps) {
+ const { selected, ariaCurrent } = useFakeTabsContext();
+ const isDisabled = href === undefined;
+ const isSelected = Object.is(selected, id);
+ const Tag = Component ?? "a";
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/evo-react/src/fake-tabs/fake-tabs.stories.tsx b/packages/evo-react/src/fake-tabs/fake-tabs.stories.tsx
new file mode 100644
index 00000000000..2362f19320c
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/fake-tabs.stories.tsx
@@ -0,0 +1,136 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { EvoFakeTab } from "./fake-tab";
+import { EvoFakeTabList } from "./fake-tab-list";
+import { EvoFakeTabPanel } from "./fake-tab-panel";
+import { EvoFakeTabs } from "./fake-tabs";
+const meta: Meta = {
+ title: "navigation & disclosure/evo-fake-tabs",
+ component: EvoFakeTabs,
+ subcomponents: {
+ EvoFakeTabList,
+ EvoFakeTab,
+ EvoFakeTabPanel,
+ },
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component: `
+A navigation-based tab strip where each tab is an anchor link. Unlike the interactive \`EvoTabs\` widget,
+fake tabs use \`aria-current\` on the selected link rather than ARIA tab roles.
+
+**Fake tabs are appropriate when each tab navigates to a distinct URL.** The selected tab reflects
+the current page, not client-side state managed by JavaScript. Omit a tab's \`href\` to render it disabled.
+
+## Usage
+
+\`\`\`tsx
+import {
+ EvoFakeTabs,
+ EvoFakeTabList,
+ EvoFakeTab,
+ EvoFakeTabPanel,
+} from "@evo-web/react/fake-tabs";
+\`\`\`
+ `,
+ },
+ },
+ },
+ argTypes: {
+ selected: {
+ control: "select",
+ options: ["overview", "shipping", "returns", null],
+ },
+ size: {
+ control: "select",
+ options: ["regular", "large"],
+ table: { defaultValue: { summary: "regular" } },
+ },
+ tabMatchesCurrentUrl: {
+ control: "boolean",
+ table: { defaultValue: { summary: "true" } },
+ },
+ },
+ args: {
+ selected: "overview",
+ size: "regular",
+ tabMatchesCurrentUrl: true,
+ },
+ render({ selected, ...args }) {
+ return (
+
+
+
+ Overview
+
+
+ Shipping
+
+
+ Returns
+
+ Contact (disabled)
+
+
+ Page content
+
+ In a server-rendered app this area contains the page content for the
+ selected tab URL.
+
+
+
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const CustomLinkAdapter: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: `
+Pass a custom \`as\` component to swap the native \`\` for a framework link.
+The component receives \`href\` and all anchor-compatible props:
+
+\`\`\`tsx
+import { Link } from "react-router";
+
+function RouterTab({ href, ...props }: React.ComponentProps<"a">) {
+ return href === undefined ? null : ;
+}
+
+Overview
+\`\`\`
+`,
+ },
+ },
+ },
+ render(args) {
+ type LinkProps = React.ComponentProps<"a">;
+ const CustomLink = ({ href, children, ...rest }: LinkProps) => (
+
+ {children}
+
+ );
+
+ return (
+
+
+
+ Overview
+
+
+ Shipping
+
+
+
+ Content rendered by the router.
+
+
+ );
+ },
+};
diff --git a/packages/evo-react/src/fake-tabs/fake-tabs.tsx b/packages/evo-react/src/fake-tabs/fake-tabs.tsx
new file mode 100644
index 00000000000..d42575cc5bd
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/fake-tabs.tsx
@@ -0,0 +1,30 @@
+import classNames from "classnames";
+import { FakeTabsProvider } from "./context";
+import type { EvoFakeTabsProps } from "./types";
+import "@ebay/skin/tabs.mjs";
+
+export function EvoFakeTabs({
+ selected,
+ size = "regular",
+ tabMatchesCurrentUrl = true,
+ className,
+ children,
+ ...rest
+}: EvoFakeTabsProps) {
+ const ariaCurrent = tabMatchesCurrentUrl ? "page" : "true";
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/evo-react/src/fake-tabs/index.ts b/packages/evo-react/src/fake-tabs/index.ts
new file mode 100644
index 00000000000..ad2e33ff1b0
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/index.ts
@@ -0,0 +1,12 @@
+export { EvoFakeTabs } from "./fake-tabs";
+export { EvoFakeTabList } from "./fake-tab-list";
+export { EvoFakeTab } from "./fake-tab";
+export { EvoFakeTabPanel } from "./fake-tab-panel";
+export type {
+ EvoFakeTabsProps,
+ EvoFakeTabListProps,
+ EvoFakeTabProps,
+ EvoFakeTabPanelProps,
+ FakeTabId,
+ Size,
+} from "./types";
diff --git a/packages/evo-react/src/fake-tabs/test/__snapshots__/test.server.tsx.snap b/packages/evo-react/src/fake-tabs/test/__snapshots__/test.server.tsx.snap
new file mode 100644
index 00000000000..6841aceed8c
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/test/__snapshots__/test.server.tsx.snap
@@ -0,0 +1,11 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`EvoFakeTabs SSR > renders large size 1`] = `""`;
+
+exports[`EvoFakeTabs SSR > renders tabMatchesCurrentUrl=false with aria-current=true 1`] = `""`;
+
+exports[`EvoFakeTabs SSR > renders with a numeric id selected 1`] = `""`;
+
+exports[`EvoFakeTabs SSR > renders with a string id selected 1`] = `""`;
+
+exports[`EvoFakeTabs SSR > renders with null selected (no aria-current) 1`] = `""`;
diff --git a/packages/evo-react/src/fake-tabs/test/test.browser.tsx b/packages/evo-react/src/fake-tabs/test/test.browser.tsx
new file mode 100644
index 00000000000..f1a2566d475
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/test/test.browser.tsx
@@ -0,0 +1,295 @@
+import { createRef } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { userEvent } from "vitest/browser";
+import { render } from "vitest-browser-react";
+import {
+ EvoFakeTab,
+ EvoFakeTabList,
+ EvoFakeTabPanel,
+ EvoFakeTabs,
+} from "../index";
+import type { FakeTabId } from "../types";
+
+function FakeTabSet({
+ selected,
+ tabMatchesCurrentUrl,
+ size,
+}: {
+ selected: FakeTabId | null;
+ tabMatchesCurrentUrl?: boolean;
+ size?: "regular" | "large";
+}) {
+ return (
+
+
+
+ US
+
+
+ Germany
+
+ UK
+
+ Tab content
+
+ );
+}
+
+describe("evo-fake-tabs", () => {
+ let user: ReturnType;
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
+
+ afterEach(() => {
+ user.cleanup();
+ });
+
+ it("renders navigation links, not ARIA tab roles", async () => {
+ const screen = await render( );
+ const links = Array.from(
+ screen.container.querySelectorAll("a[href]"),
+ );
+ // Only enabled tabs are links
+ expect(links).toHaveLength(2);
+ // No ARIA tab roles
+ expect(screen.container.querySelector('[role="tab"]')).toBeNull();
+ expect(screen.container.querySelector('[role="tablist"]')).toBeNull();
+ });
+
+ it("sets aria-current=page on the selected tab", async () => {
+ const screen = await render( );
+ const usLink = screen.getByRole("link", { name: "US" });
+ await expect.element(usLink).toHaveAttribute("aria-current", "page");
+
+ const deLink = screen.getByRole("link", { name: "Germany" });
+ await expect.element(deLink).not.toHaveAttribute("aria-current");
+ });
+
+ it("sets aria-current=true when tabMatchesCurrentUrl is false", async () => {
+ const screen = await render(
+ ,
+ );
+ const usLink = screen.getByRole("link", { name: "US" });
+ await expect.element(usLink).toHaveAttribute("aria-current", "true");
+ });
+
+ it("sets no aria-current when selected is null", async () => {
+ const screen = await render( );
+ const links = Array.from(
+ screen.container.querySelectorAll("a"),
+ );
+ for (const link of links) {
+ expect(link.hasAttribute("aria-current")).toBe(false);
+ }
+ });
+
+ it("tab without href has no href and aria-disabled=true", async () => {
+ const screen = await render( );
+ const ukText = screen.getByText("UK");
+ await expect.element(ukText).not.toHaveAttribute("href");
+ await expect.element(ukText).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("tab without href has no aria-current even when id matches selected", async () => {
+ const screen = await render( );
+ const ukEl = screen.getByText("UK");
+ await expect.element(ukEl).not.toHaveAttribute("aria-current");
+ });
+
+ it("uses the custom component and suppresses clicks when href is omitted", async () => {
+ const onClick = vi.fn();
+ const CustomLink = ({
+ children,
+ href,
+ ...rest
+ }: React.ComponentProps<"a">) => (
+
+ {children}
+
+ );
+ const screen = await render(
+
+
+
+ Disabled
+
+
+ ,
+ );
+
+ const disabledTab = screen.getByText("Disabled");
+ await expect
+ .element(disabledTab)
+ .toHaveAttribute("data-custom-link", "true");
+
+ await user.click(disabledTab);
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("treats an empty href as an enabled custom link", async () => {
+ const onClick = vi.fn();
+ const CustomLink = ({
+ children,
+ href,
+ onClick,
+ ...rest
+ }: React.ComponentProps<"a">) => (
+ {
+ event.preventDefault();
+ if (onClick) {
+ onClick(event);
+ }
+ }}
+ >
+ {children}
+
+ );
+ const screen = await render(
+
+
+
+ Empty href
+
+
+ ,
+ );
+
+ const link = screen.getByRole("link", { name: "Empty href" });
+ await expect.element(link).toHaveAttribute("href", "");
+ await expect.element(link).toHaveAttribute("aria-current", "page");
+ await expect.element(link).toHaveAttribute("data-custom-link", "true");
+
+ await user.click(link);
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("applies custom classes to the list item", async () => {
+ const screen = await render(
+
+
+
+ US
+
+
+ ,
+ );
+
+ const item = screen.getByRole("listitem");
+ await expect.element(item).toHaveClass("fake-tabs__item", "custom-item");
+ await expect
+ .element(screen.getByRole("link", { name: "US" }))
+ .not.toHaveClass("custom-item");
+ });
+
+ it("applies the large size class", async () => {
+ const screen = await render( );
+ const root = screen.container.querySelector(".fake-tabs");
+ expect(root).not.toBeNull();
+ expect(root!.classList.contains("fake-tabs--large")).toBe(true);
+ });
+
+ it("does not apply large class for regular size", async () => {
+ const screen = await render( );
+ const root = screen.container.querySelector(".fake-tabs");
+ expect(root!.classList.contains("fake-tabs--large")).toBe(false);
+ });
+
+ it("renders the wrapper hierarchy: fake-tabs__content > fake-tabs__panel > fake-tabs__cell > div", async () => {
+ const screen = await render( );
+ const content = screen.container.querySelector(".fake-tabs__content");
+ expect(content).not.toBeNull();
+ const panel = content!.querySelector(".fake-tabs__panel");
+ expect(panel).not.toBeNull();
+ const cell = panel!.querySelector(".fake-tabs__cell");
+ expect(cell).not.toBeNull();
+ const inner = cell!.querySelector("div");
+ expect(inner).not.toBeNull();
+ expect(inner!.textContent).toBe("Tab content");
+ });
+
+ it("forwards native props and refs to structural elements", async () => {
+ const rootRef = createRef();
+ const listRef = createRef();
+ const panelRef = createRef();
+ await render(
+
+
+
+ US
+
+
+
+ Content
+
+ ,
+ );
+
+ expect(rootRef.current).toBeInstanceOf(HTMLDivElement);
+ expect(rootRef.current?.id).toBe("my-fake-tabs");
+ expect(listRef.current).toBeInstanceOf(HTMLUListElement);
+ expect(listRef.current?.getAttribute("aria-label")).toBe("Destinations");
+ expect(panelRef.current).toBeInstanceOf(HTMLDivElement);
+ expect(panelRef.current?.dataset.testid).toBe("panel");
+ });
+
+ it("as prop receives href and renders a custom anchor component", async () => {
+ type LinkProps = React.ComponentProps<"a">;
+ const CustomLink = ({ href, children, ...rest }: LinkProps) => (
+
+ {children}
+
+ );
+
+ const screen = await render(
+
+
+
+ US
+
+
+ Germany
+
+
+ ,
+ );
+
+ const usLink = screen.getByRole("link", { name: "US" });
+ await expect.element(usLink).toHaveAttribute("data-custom-link", "true");
+ await expect.element(usLink).toHaveAttribute("href", "/us");
+ await expect.element(usLink).toHaveAttribute("aria-current", "page");
+
+ // Germany uses the default without data-custom-link
+ const deLink = screen.getByRole("link", { name: "Germany" });
+ await expect.element(deLink).not.toHaveAttribute("data-custom-link");
+ });
+
+ it("uses Object.is for id comparison (numeric id)", async () => {
+ const screen = await render(
+
+
+
+ Numeric
+
+
+ String 42
+
+
+ ,
+ );
+
+ const numLink = screen.getByRole("link", { name: "Numeric" });
+ const strLink = screen.getByRole("link", { name: "String 42" });
+ await expect.element(numLink).toHaveAttribute("aria-current", "page");
+ await expect.element(strLink).not.toHaveAttribute("aria-current");
+ });
+});
diff --git a/packages/evo-react/src/fake-tabs/test/test.server.tsx b/packages/evo-react/src/fake-tabs/test/test.server.tsx
new file mode 100644
index 00000000000..db766ca7ad7
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/test/test.server.tsx
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import { renderToString } from "react-dom/server";
+import {
+ EvoFakeTab,
+ EvoFakeTabList,
+ EvoFakeTabPanel,
+ EvoFakeTabs,
+} from "../index";
+
+function FakeTabsFixture({
+ selected,
+ size,
+ tabMatchesCurrentUrl,
+}: {
+ selected: string | number | null;
+ size?: "regular" | "large";
+ tabMatchesCurrentUrl?: boolean;
+}) {
+ return (
+
+
+
+ US
+
+
+ Germany
+
+
+ Numeric
+
+ UK
+
+ Tab content
+
+ );
+}
+
+describe("EvoFakeTabs SSR", () => {
+ it("renders with a string id selected", () => {
+ expect(renderToString( )).toMatchSnapshot();
+ });
+
+ it("renders with a numeric id selected", () => {
+ expect(renderToString( )).toMatchSnapshot();
+ });
+
+ it("renders with null selected (no aria-current)", () => {
+ expect(
+ renderToString( ),
+ ).toMatchSnapshot();
+ });
+
+ it("renders tabMatchesCurrentUrl=false with aria-current=true", () => {
+ expect(
+ renderToString(
+ ,
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it("renders large size", () => {
+ expect(
+ renderToString( ),
+ ).toMatchSnapshot();
+ });
+
+ it("renders a tab without href as disabled", () => {
+ const html = renderToString( );
+ expect(html).toContain('aria-disabled="true"');
+ expect(html).not.toContain('href="/uk"');
+ });
+});
diff --git a/packages/evo-react/src/fake-tabs/types.ts b/packages/evo-react/src/fake-tabs/types.ts
new file mode 100644
index 00000000000..05da7091610
--- /dev/null
+++ b/packages/evo-react/src/fake-tabs/types.ts
@@ -0,0 +1,42 @@
+import type { ComponentProps, ComponentType } from "react";
+
+/** A value that uniquely identifies a fake tab and matches it to the EvoFakeTabs selected prop. */
+export type FakeTabId = string | number;
+
+/** Size of the fake tab strip. */
+export type Size = "regular" | "large";
+
+export type EvoFakeTabsProps = ComponentProps<"div"> & {
+ /**
+ * The id of the currently selected tab.
+ * Pass `null` explicitly when no tab corresponds to the current page.
+ */
+ selected: FakeTabId | null;
+ /** Size of the tab strip. Defaults to `"regular"`. */
+ size?: Size;
+ /**
+ * Controls the `aria-current` value on the selected tab link.
+ * When `true` (default), the selected link gets `aria-current="page"`.
+ * When `false`, it gets `aria-current="true"`.
+ */
+ tabMatchesCurrentUrl?: boolean;
+};
+
+export type EvoFakeTabListProps = ComponentProps<"ul">;
+
+export type EvoFakeTabProps = Omit<
+ ComponentProps<"a">,
+ "aria-current" | "aria-disabled" | "href" | "id"
+> & {
+ /** Unique identifier matching this tab to the parent EvoFakeTabs `selected` prop. */
+ id: FakeTabId;
+ /** Link destination. Omit to disable the tab. */
+ href?: string;
+ /**
+ * Custom component used in place of the native anchor, such as a framework Link.
+ * The component receives `href` and all other anchor-compatible props.
+ */
+ as?: ComponentType>;
+};
+
+export type EvoFakeTabPanelProps = ComponentProps<"div">;
diff --git a/packages/evo-react/src/tabs/index.ts b/packages/evo-react/src/tabs/index.ts
index edf136797e9..eec16767927 100644
--- a/packages/evo-react/src/tabs/index.ts
+++ b/packages/evo-react/src/tabs/index.ts
@@ -1,13 +1,13 @@
export { EvoTabs } from "./tabs";
export { EvoTabList } from "./tab-list";
export { EvoTab } from "./tab";
-export { EvoTabPanelList } from "./tab-panel-list";
+export { EvoTabPanels } from "./tab-panels";
export { EvoTabPanel } from "./tab-panel";
export type {
Activation,
EvoTabListProps,
EvoTabPanelProps,
- EvoTabPanelListProps,
+ EvoTabPanelsProps,
EvoTabProps,
EvoTabsProps,
Size,
diff --git a/packages/evo-react/src/tabs/tab-panel-list.tsx b/packages/evo-react/src/tabs/tab-panels.tsx
similarity index 62%
rename from packages/evo-react/src/tabs/tab-panel-list.tsx
rename to packages/evo-react/src/tabs/tab-panels.tsx
index 8cc74e6a244..8cb8bd0a250 100644
--- a/packages/evo-react/src/tabs/tab-panel-list.tsx
+++ b/packages/evo-react/src/tabs/tab-panels.tsx
@@ -1,11 +1,11 @@
import classNames from "classnames";
-import type { EvoTabPanelListProps } from "./types";
+import type { EvoTabPanelsProps } from "./types";
-export function EvoTabPanelList({
+export function EvoTabPanels({
className,
children,
...rest
-}: EvoTabPanelListProps) {
+}: EvoTabPanelsProps) {
return (
{children}
diff --git a/packages/evo-react/src/tabs/tabs.stories.tsx b/packages/evo-react/src/tabs/tabs.stories.tsx
index 8158ed6fb3b..11e0de170da 100644
--- a/packages/evo-react/src/tabs/tabs.stories.tsx
+++ b/packages/evo-react/src/tabs/tabs.stories.tsx
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { EvoTab } from "./tab";
import { EvoTabList } from "./tab-list";
import { EvoTabPanel } from "./tab-panel";
-import { EvoTabPanelList } from "./tab-panel-list";
+import { EvoTabPanels } from "./tab-panels";
import { EvoTabs } from "./tabs";
const meta: Meta
= {
@@ -11,7 +11,7 @@ const meta: Meta = {
subcomponents: {
EvoTabList,
EvoTab,
- EvoTabPanelList,
+ EvoTabPanels,
EvoTabPanel,
},
tags: ["autodocs"],
@@ -28,7 +28,7 @@ import {
EvoTabs,
EvoTabList,
EvoTab,
- EvoTabPanelList,
+ EvoTabPanels,
EvoTabPanel,
} from "@evo-web/react/tabs";
\`\`\`
@@ -80,7 +80,7 @@ import {
Returns
-
+
Overview
Product overview content.
@@ -93,7 +93,7 @@ import {
Returns
Returns content.
-
+
>
);
diff --git a/packages/evo-react/src/tabs/test/test.browser.tsx b/packages/evo-react/src/tabs/test/test.browser.tsx
index c47766ebf9d..e5c6fcc2085 100644
--- a/packages/evo-react/src/tabs/test/test.browser.tsx
+++ b/packages/evo-react/src/tabs/test/test.browser.tsx
@@ -6,7 +6,7 @@ import {
EvoTab,
EvoTabList,
EvoTabPanel,
- EvoTabPanelList,
+ EvoTabPanels,
EvoTabs,
} from "../index";
import type { EvoTabsProps, TabId } from "../types";
@@ -21,11 +21,11 @@ function TabSet(props: EvoTabsProps) {
Returns
-
+
Overview panel
Shipping panel
Returns panel
-
+
);
}
@@ -193,9 +193,9 @@ describe("evo-tabs", () => {
Overview
-
+
Overview panel
-
+
,
);
diff --git a/packages/evo-react/src/tabs/test/test.server.tsx b/packages/evo-react/src/tabs/test/test.server.tsx
index 74e854f2e47..6452fb6e99d 100644
--- a/packages/evo-react/src/tabs/test/test.server.tsx
+++ b/packages/evo-react/src/tabs/test/test.server.tsx
@@ -4,7 +4,7 @@ import {
EvoTab,
EvoTabList,
EvoTabPanel,
- EvoTabPanelList,
+ EvoTabPanels,
EvoTabs,
} from "../index";
@@ -21,10 +21,10 @@ function TabsFixture({
Overview
Shipping
-
+
Overview panel
Shipping panel
-
+
);
}
diff --git a/packages/evo-react/src/tabs/types.ts b/packages/evo-react/src/tabs/types.ts
index fb79bebade5..070c78db94e 100644
--- a/packages/evo-react/src/tabs/types.ts
+++ b/packages/evo-react/src/tabs/types.ts
@@ -49,7 +49,7 @@ export type EvoTabProps = Omit<
disabled?: boolean;
};
-export type EvoTabPanelListProps = ComponentProps<"div">;
+export type EvoTabPanelsProps = ComponentProps<"div">;
export type EvoTabPanelProps = Omit<
ComponentProps<"div">,