diff --git a/.changeset/add-evo-details-leading.md b/.changeset/add-evo-details-leading.md
new file mode 100644
index 0000000000..1d03f2384b
--- /dev/null
+++ b/.changeset/add-evo-details-leading.md
@@ -0,0 +1,5 @@
+---
+"@evo-web/marko": patch
+---
+
+Add leading attribute tag to evo-details
diff --git a/.changeset/add-evo-details-react.md b/.changeset/add-evo-details-react.md
new file mode 100644
index 0000000000..8ad312b0d7
--- /dev/null
+++ b/.changeset/add-evo-details-react.md
@@ -0,0 +1,5 @@
+---
+"@evo-web/react": patch
+---
+
+Add evo-details component
diff --git a/.claude/skills/evo-app-migrate-react/SKILL.md b/.claude/skills/evo-app-migrate-react/SKILL.md
index 1e8bb3a8e5..66bdbbfd77 100644
--- a/.claude/skills/evo-app-migrate-react/SKILL.md
+++ b/.claude/skills/evo-app-migrate-react/SKILL.md
@@ -55,6 +55,60 @@ If a component is not listed below, it has **not been migrated yet** — keep us
No prop changes. Global renames (Step 2) are sufficient.
+### `ebay-details`
+
+This component has a **new composite API** in evo-react. The flat-prop approach is replaced by named sub-components.
+
+**Before:**
+
+```tsx
+import { EbayDetails } from "@ebay/ebayui-core-react/ebay-details";
+
+ }
+ onToggle={handler}
+>
+ Content here
+;
+```
+
+**After:**
+
+```tsx
+import {
+ EvoDetails,
+ EvoDetailsSummary,
+ EvoDetailsLeading,
+ EvoDetailsLabel,
+ EvoDetailsContent,
+} from "@evo-web/react";
+
+
+
+
+
+
+ Show me the details!
+
+ Content here
+ ;
+```
+
+**Prop changes:**
+
+| ebayui-core-react | evo-react | Notes |
+| ------------------------ | ----------------------------------- | ---------------------------------------------------------------------------- |
+| `text: string` | `` sub-component | Move label text into `` inside `` |
+| `leading?: ReactElement` | `` sub-component | Move leading element into `` inside `` |
+| `as?: ElementType` | `as` prop on `` | Move to the content sub-component |
+| `onToggle` | `onToggle` | Same signature: `(event, { open: boolean }) => void` |
+| `children` | `` children | Wrap children in `` |
+
+**Important:** `` must appear before `` inside `` — order is not enforced by the component.
+
---
## Step 4 — Verify
diff --git a/.claude/skills/evo-migrate-react/SKILL.md b/.claude/skills/evo-migrate-react/SKILL.md
index 3fb58d7722..2c7788dcd0 100644
--- a/.claude/skills/evo-migrate-react/SKILL.md
+++ b/.claude/skills/evo-migrate-react/SKILL.md
@@ -43,6 +43,7 @@ packages/evo-react/src/evo-{name}/
{name}.tsx ← main component
{subcomponent-name}.tsx ← sub-components if present (named after actual sub-component, e.g. button-cell.tsx)
types.ts ← all exported types
+ context.ts ← React context + hook (only if component uses context)
README.md ← component name + Documentation section with Storybook link only
{name}.stories.tsx ← Storybook stories (co-located, NOT in __tests__/)
test/
@@ -66,6 +67,23 @@ packages/evo-react/src/evo-{name}/
## Component authoring rules
+### No `import React from "react"` unless required for typing
+
+`@evo-web/react` uses the automatic JSX transform — the JSX runtime is injected automatically and `React` does not need to be imported for JSX. Import only what you actually use as named imports:
+
+```tsx
+// ✅ evo-react — import only what is needed
+import type { ComponentProps, SyntheticEvent } from "react";
+import classNames from "classnames";
+
+// ❌ do NOT import the default React object unless unavoidable
+import React from "react";
+```
+
+The only time `import React from "react"` is acceptable is when you need the namespace for a specific type like `React.JSX.Element` in overloaded signatures, and even then prefer `import type { JSX } from "react"` with `JSX.Element`.
+
+---
+
### Named function declarations — no `FC`, no arrow function components
```tsx
@@ -129,6 +147,8 @@ import { EvoIconChevronDown16 } from "../evo-icon/icons/evo-icon-chevron-down-16
```
+This applies to **stories and tests** as well — never use inline `` or custom placeholder icons. Always use an existing `EvoIcon*` component. Available icons are in `packages/evo-react/src/evo-icon/icons/`.
+
### Optional callbacks — no required default `() => {}`
```tsx
@@ -177,11 +197,13 @@ export function EvoButton({ a11yText, ...rest }: EvoButtonProps) {
Do **not** use `Children.map`, `Children.toArray`, `findComponent`, `filterComponent`, or any child-scanning pattern.
-If the ebayui-core-react component uses children composition (e.g. finding a sub-component in children), **stop and ask** before proceeding. Propose one or more alternative approaches using explicit props instead of child scanning, for example:
+The **preferred approach** is named sub-components with React context (see [ADR 0005](../../../docs/adr/0005-evo-react-child-component-composition.md)). This is the established pattern in `@evo-web/react` and should be the default proposal for consistency.
+
+If the ebayui-core-react component uses children composition (e.g. finding a sub-component in children), **stop and ask** before proceeding. Lead with the sub-component approach as the recommendation, but present the full picture so the user can confirm:
-- Accepting sub-component content as a named prop (`footer`, `header`, `title`)
-- Accepting a render prop
-- Splitting into separate sibling components
+1. What sub-components would be needed, and what state (if any) the parent must share via context.
+2. Whether a simpler alternative fits — e.g. a named prop (`footer`, `header`, `title`) if the region is a single, unstructured slot with no BEM class injection needed.
+3. Any edge cases specific to this component that could affect the choice (e.g. enforced ordering, complex shared state, accessibility requirements).
Do not guess — get alignment before migrating this pattern.
@@ -269,7 +291,7 @@ describe("EvoButton SSR", () => {
## Storybook stories — `{name}.stories.tsx`
-- One story per component whenever possible. Only add multiple stories when variations require different component structure that cannot be expressed through args/argTypes alone.
+- **One story per component** unless the component tree itself must change between variations (e.g. different sub-components, optional children). Visual and prop variations (size, alignment, disabled, open…) must be handled through `args` and `argTypes` controls — not separate stories.
- `title` must mirror the ebayui-core-react story title with `ebay` replaced by `evo`.
- Description format: one-sentence summary followed by a `## Usage` section with the import snippet.
diff --git a/docs/adr/0005-evo-react-child-component-composition.md b/docs/adr/0005-evo-react-child-component-composition.md
new file mode 100644
index 0000000000..ae338fff20
--- /dev/null
+++ b/docs/adr/0005-evo-react-child-component-composition.md
@@ -0,0 +1,48 @@
+# 5. Evo React Child Component Composition
+
+**Date:** 2026-04-23
+
+## Status
+
+Accepted
+
+## Context
+
+`@ebay/ui-core-react` uses `React.Children.map` / `findComponent` to introspect children and wire up composite component structures. This API is fragile, order-sensitive, and deprecated by React.
+
+### Alternatives considered
+
+- **`summary={ }`** — requires `React.cloneElement` to inject BEM classes into the passed element, which is equally discouraged.
+- **`summary={{ children: "", className, ...spanProps }}`** — avoids cloning but forces consumers to pass a plain object instead of JSX, which is poor DX.
+
+## Decision
+
+`@evo-web/react` uses **named sub-components** (compound component pattern) instead of `React.Children` scanning. Shared state is passed via React context from the parent. Sub-components consume only what they need.
+
+```tsx
+
+
+
+
+
+ How do I get started?
+
+ Content
+
+```
+
+All sub-components are exported from the package entry point.
+
+## Consequences
+
+### Positive
+
+- More option for developers to customize the layout when possible.
+- Better DX with simpler to use component APIs.
+- Removes dependency of `React.Children` APIs and `React.cloneElement`.
+
+### Negative
+
+- Child rendering order is the consumer's responsibility — the parent does not enforce it.
+- The evo-react API will diverge from evo-marko's attribute tag pattern (`<@summary>`), which is a Marko 6-specific construct with no React equivalent.
+- More verbose component APIs
diff --git a/packages/evo-marko/src/tags/evo-details/details.stories.ts b/packages/evo-marko/src/tags/evo-details/details.stories.ts
index 06aa8675ed..5a65b2af53 100644
--- a/packages/evo-marko/src/tags/evo-details/details.stories.ts
+++ b/packages/evo-marko/src/tags/evo-details/details.stories.ts
@@ -4,6 +4,8 @@ import Readme from "./README.md";
import Details, { type Input } from "./index.marko";
import DefaultTemplate from "./examples/default.marko";
import DefaultTemplateCode from "./examples/default.marko?raw";
+import WithLeadingTemplate from "./examples/with-leading.marko";
+import WithLeadingTemplateCode from "./examples/with-leading.marko?raw";
export default {
title: "navigation & disclosure/evo-details",
@@ -18,6 +20,15 @@ export default {
argTypes: {
content: {},
+ leading: {
+ description: "Optional leading element (e.g. an icon) rendered before the summary label",
+ "@": {
+ [" attributes" as any]: {
+ description:
+ "All attributes and event handlers from [the native HTML `` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/span) will be passed through",
+ },
+ },
+ },
summary: {
description: "The body which will be wrapped as the details summary",
"@": {
@@ -63,3 +74,8 @@ export const Default = buildExtensionTemplate(
DefaultTemplate,
DefaultTemplateCode,
);
+
+export const WithLeading = buildExtensionTemplate(
+ WithLeadingTemplate,
+ WithLeadingTemplateCode,
+);
diff --git a/packages/evo-marko/src/tags/evo-details/examples/with-leading.marko b/packages/evo-marko/src/tags/evo-details/examples/with-leading.marko
new file mode 100644
index 0000000000..cc7bd784d7
--- /dev/null
+++ b/packages/evo-marko/src/tags/evo-details/examples/with-leading.marko
@@ -0,0 +1,5 @@
+
+ <@summary>Details@summary>
+ <@leading> @leading>
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
+
diff --git a/packages/evo-marko/src/tags/evo-details/index.marko b/packages/evo-marko/src/tags/evo-details/index.marko
index 0402fb2754..ea8e22a39b 100644
--- a/packages/evo-marko/src/tags/evo-details/index.marko
+++ b/packages/evo-marko/src/tags/evo-details/index.marko
@@ -1,5 +1,6 @@
export interface Input extends Marko.Input<"details"> {
summary?: Marko.AttrTag>;
+ leading?: Marko.AttrTag>;
size?: "regular" | "small";
alignment?: "regular" | "center";
contentAs?: keyof Marko.NativeTags;
@@ -12,6 +13,7 @@ export interface Input extends Marko.Input<"details"> {
alignment,
size,
summary,
+ leading,
content,
contentAs,
...htmlInput
@@ -24,6 +26,9 @@ export interface Input extends Marko.Input<"details"> {
size === "small" && "details__summary--small",
alignment === "center" && "details__summary--center",
]>
+
+
+
diff --git a/packages/evo-marko/src/tags/evo-details/test/test.server.ts b/packages/evo-marko/src/tags/evo-details/test/test.server.ts
index 459a78d667..812eec02d1 100644
--- a/packages/evo-marko/src/tags/evo-details/test/test.server.ts
+++ b/packages/evo-marko/src/tags/evo-details/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 "../details.stories"; // import all stories from the stories file
-const { Default } = composeStories(stories);
+const { Default, WithLeading } = composeStories(stories);
describe("details", () => {
it("renders basic version", async () => {
@@ -24,4 +24,8 @@ describe("details", () => {
it("renders center version", async () => {
await snapshotHTML(Default, {alignment: "center" });
});
+
+ it("renders with leading element", async () => {
+ await snapshotHTML(WithLeading);
+ });
});
diff --git a/packages/evo-react/src/evo-details/README.md b/packages/evo-react/src/evo-details/README.md
new file mode 100644
index 0000000000..f01563f661
--- /dev/null
+++ b/packages/evo-react/src/evo-details/README.md
@@ -0,0 +1,5 @@
+# EvoDetails
+
+## Documentation
+
+[Storybook](https://opensource.ebay.com/evo-web/react/main/?path=/docs/navigation-disclosure-evo-details--documentation)
diff --git a/packages/evo-react/src/evo-details/context.ts b/packages/evo-react/src/evo-details/context.ts
new file mode 100644
index 0000000000..3b85c8e23e
--- /dev/null
+++ b/packages/evo-react/src/evo-details/context.ts
@@ -0,0 +1,13 @@
+import { createContext, useContext } from "react";
+import type { Size, Alignment } from "./types";
+
+export type DetailsContextValue = {
+ size?: Size;
+ alignment?: Alignment;
+};
+
+export const DetailsContext = createContext({});
+
+export function useDetailsContext() {
+ return useContext(DetailsContext);
+}
diff --git a/packages/evo-react/src/evo-details/details-content.tsx b/packages/evo-react/src/evo-details/details-content.tsx
new file mode 100644
index 0000000000..4dff8945bc
--- /dev/null
+++ b/packages/evo-react/src/evo-details/details-content.tsx
@@ -0,0 +1,15 @@
+import classNames from "classnames";
+import type { EvoDetailsContentProps } from "./types";
+
+export function EvoDetailsContent({
+ as: Component = "div",
+ children,
+ className,
+ ...rest
+}: EvoDetailsContentProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/evo-react/src/evo-details/details-label.tsx b/packages/evo-react/src/evo-details/details-label.tsx
new file mode 100644
index 0000000000..0c7a5c2da7
--- /dev/null
+++ b/packages/evo-react/src/evo-details/details-label.tsx
@@ -0,0 +1,14 @@
+import classNames from "classnames";
+import type { EvoDetailsLabelProps } from "./types";
+
+export function EvoDetailsLabel({
+ children,
+ className,
+ ...rest
+}: EvoDetailsLabelProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/evo-react/src/evo-details/details-leading.tsx b/packages/evo-react/src/evo-details/details-leading.tsx
new file mode 100644
index 0000000000..373d8a45ff
--- /dev/null
+++ b/packages/evo-react/src/evo-details/details-leading.tsx
@@ -0,0 +1,14 @@
+import classNames from "classnames";
+import type { EvoDetailsLeadingProps } from "./types";
+
+export function EvoDetailsLeading({
+ children,
+ className,
+ ...rest
+}: EvoDetailsLeadingProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/evo-react/src/evo-details/details-summary.tsx b/packages/evo-react/src/evo-details/details-summary.tsx
new file mode 100644
index 0000000000..087f350f75
--- /dev/null
+++ b/packages/evo-react/src/evo-details/details-summary.tsx
@@ -0,0 +1,29 @@
+import classNames from "classnames";
+import type { EvoDetailsSummaryProps } from "./types";
+import { useDetailsContext } from "./context";
+import { EvoIconChevronDown16 } from "../evo-icon/icons/evo-icon-chevron-down-16";
+
+export function EvoDetailsSummary({
+ children,
+ className,
+ ...rest
+}: EvoDetailsSummaryProps) {
+ const { size, alignment } = useDetailsContext();
+
+ return (
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/packages/evo-react/src/evo-details/details.stories.tsx b/packages/evo-react/src/evo-details/details.stories.tsx
new file mode 100644
index 0000000000..4b772de345
--- /dev/null
+++ b/packages/evo-react/src/evo-details/details.stories.tsx
@@ -0,0 +1,94 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { EvoDetails } from "./details";
+import { EvoDetailsSummary } from "./details-summary";
+import { EvoDetailsLeading } from "./details-leading";
+import { EvoDetailsLabel } from "./details-label";
+import { EvoDetailsContent } from "./details-content";
+import { EvoIconLightbulb16 } from "../evo-icon/icons/evo-icon-lightbulb-16";
+
+const LOREM =
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";
+
+const meta: Meta = {
+ title: "navigation & disclosure/evo-details",
+ component: EvoDetails,
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component: `
+A disclosure component that shows/hides content using the native \`\` element. Composed using sub-components: \`EvoDetailsSummary\`, \`EvoDetailsLabel\`, \`EvoDetailsLeading\`, and \`EvoDetailsContent\`.
+
+## Usage
+
+\`\`\`tsx
+import {
+ EvoDetails,
+ EvoDetailsSummary,
+ EvoDetailsLabel,
+ EvoDetailsLeading,
+ EvoDetailsContent,
+} from "@evo-web/react";
+\`\`\`
+ `,
+ },
+ },
+ },
+ argTypes: {
+ size: {
+ control: "select",
+ options: ["regular", "small"],
+ description: "Size of the summary",
+ table: { defaultValue: { summary: "regular" } },
+ },
+ alignment: {
+ control: "select",
+ options: ["regular", "center"],
+ description: "Alignment of the summary",
+ table: { defaultValue: { summary: "regular" } },
+ },
+ open: {
+ control: "boolean",
+ description: "Whether the details is open",
+ table: { defaultValue: { summary: "false" } },
+ },
+ onToggle: {
+ action: "onToggle",
+ description: "Fired on toggle with `(event, { open })` arguments",
+ table: { category: "Events" },
+ },
+ },
+ args: {
+ size: "regular",
+ alignment: "regular",
+ open: false,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+ Show me the details!
+
+ {LOREM}
+
+ ),
+};
+
+export const WithLeading: Story = {
+ render: (args) => (
+
+
+
+
+
+ Show me the details!
+
+ {LOREM}
+
+ ),
+};
diff --git a/packages/evo-react/src/evo-details/details.tsx b/packages/evo-react/src/evo-details/details.tsx
new file mode 100644
index 0000000000..94cc5c8a9a
--- /dev/null
+++ b/packages/evo-react/src/evo-details/details.tsx
@@ -0,0 +1,34 @@
+import type { SyntheticEvent } from "react";
+import classNames from "classnames";
+import type { EvoDetailsProps } from "./types";
+import { DetailsContext } from "./context";
+import "@ebay/skin/details.mjs";
+
+export function EvoDetails({
+ size,
+ alignment,
+ open,
+ onToggle,
+ children,
+ className,
+ ...rest
+}: EvoDetailsProps) {
+ const handleToggle =
+ onToggle &&
+ ((event: SyntheticEvent) => {
+ onToggle(event, { open: event.currentTarget.open });
+ });
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/evo-react/src/evo-details/index.ts b/packages/evo-react/src/evo-details/index.ts
new file mode 100644
index 0000000000..370b1384cc
--- /dev/null
+++ b/packages/evo-react/src/evo-details/index.ts
@@ -0,0 +1,14 @@
+export { EvoDetails } from "./details";
+export { EvoDetailsSummary } from "./details-summary";
+export { EvoDetailsLeading } from "./details-leading";
+export { EvoDetailsLabel } from "./details-label";
+export { EvoDetailsContent } from "./details-content";
+export type {
+ EvoDetailsProps,
+ EvoDetailsSummaryProps,
+ EvoDetailsLeadingProps,
+ EvoDetailsLabelProps,
+ EvoDetailsContentProps,
+ Size,
+ Alignment,
+} from "./types";
diff --git a/packages/evo-react/src/evo-details/test/__snapshots__/test.server.tsx.snap b/packages/evo-react/src/evo-details/test/__snapshots__/test.server.tsx.snap
new file mode 100644
index 0000000000..4cb6ae7d76
--- /dev/null
+++ b/packages/evo-react/src/evo-details/test/__snapshots__/test.server.tsx.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`EvoDetails SSR > renders default state 1`] = `"Show me the details! Content
"`;
+
+exports[`EvoDetails SSR > renders with alignment=center 1`] = `"Show me the details! Content
"`;
+
+exports[`EvoDetails SSR > renders with alignment=regular 1`] = `"Show me the details! Content
"`;
+
+exports[`EvoDetails SSR > renders with custom content element via \`as\` 1`] = `"Details Paragraph content
"`;
+
+exports[`EvoDetails SSR > renders with leading element 1`] = `"Show me the details! Content
"`;
+
+exports[`EvoDetails SSR > renders with open=true 1`] = `"Show me the details! Content
"`;
+
+exports[`EvoDetails SSR > renders with size=regular 1`] = `"Show me the details! Content
"`;
+
+exports[`EvoDetails SSR > renders with size=small 1`] = `"Show me the details! Content
"`;
diff --git a/packages/evo-react/src/evo-details/test/test.browser.tsx b/packages/evo-react/src/evo-details/test/test.browser.tsx
new file mode 100644
index 0000000000..d29b6e3a8d
--- /dev/null
+++ b/packages/evo-react/src/evo-details/test/test.browser.tsx
@@ -0,0 +1,118 @@
+import type { ComponentProps } from "react";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render } from "vitest-browser-react";
+import { userEvent } from "vitest/browser";
+import { EvoDetails } from "../details";
+import { EvoDetailsSummary } from "../details-summary";
+import { EvoDetailsLabel } from "../details-label";
+import { EvoDetailsLeading } from "../details-leading";
+import { EvoDetailsContent } from "../details-content";
+import { EvoIconLightbulb16 } from "../../evo-icon/icons/evo-icon-lightbulb-16";
+
+function renderDetails(
+ props: Partial> = {},
+) {
+ return render(
+
+
+ Details
+
+ Content
+ ,
+ );
+}
+
+describe("evo-details", () => {
+ let user: ReturnType;
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
+
+ afterEach(() => {
+ user.cleanup();
+ });
+
+ it("renders with open=false by default", async () => {
+ const screen = await renderDetails();
+ const details = screen
+ .getByText("Details")
+ .element()
+ .closest("details") as HTMLDetailsElement;
+ expect(details.open).toBe(false);
+ });
+
+ it("renders with open=true when passed open prop", async () => {
+ const screen = await renderDetails({ open: true });
+ const details = screen
+ .getByText("Details")
+ .element()
+ .closest("details") as HTMLDetailsElement;
+ expect(details.open).toBe(true);
+ });
+
+ it("fires onToggle with open=true when opened", async () => {
+ const onToggle = vi.fn();
+ const screen = await renderDetails({ onToggle });
+
+ await user.click(screen.getByText("Details"));
+
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ expect(onToggle).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "toggle" }),
+ { open: true },
+ );
+ });
+
+ it("fires onToggle with open=false when closed", async () => {
+ const onToggle = vi.fn();
+ const screen = await renderDetails({ open: true, onToggle });
+
+ await user.click(screen.getByText("Details"));
+
+ expect(onToggle).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "toggle" }),
+ { open: false },
+ );
+ });
+
+ it("applies small size class to summary", async () => {
+ const screen = await renderDetails({ size: "small" });
+ const summary = screen.getByText("Details").element().closest("summary");
+ expect(summary).toHaveClass("details__summary--small");
+ });
+
+ it("applies center alignment class to summary", async () => {
+ const screen = await renderDetails({ alignment: "center" });
+ const summary = screen.getByText("Details").element().closest("summary");
+ expect(summary).toHaveClass("details__summary--center");
+ });
+
+ it("renders leading element when EvoDetailsLeading is used", async () => {
+ const screen = await render(
+
+
+
+
+
+ Details
+
+ Content
+ ,
+ );
+
+ await expect.element(screen.getByTestId("icon")).toBeInTheDocument();
+ });
+
+ it("renders content inside EvoDetailsContent", async () => {
+ const screen = await renderDetails();
+ await expect.element(screen.getByText("Content")).toBeInTheDocument();
+ });
+
+ it("renders the chevron icon inside summary", async () => {
+ const screen = await renderDetails();
+ const summary = screen.getByText("Details").element().closest("summary");
+ const iconSpan = summary?.querySelector(".details__icon");
+ expect(iconSpan).toBeTruthy();
+ });
+});
diff --git a/packages/evo-react/src/evo-details/test/test.server.tsx b/packages/evo-react/src/evo-details/test/test.server.tsx
new file mode 100644
index 0000000000..1785aac79c
--- /dev/null
+++ b/packages/evo-react/src/evo-details/test/test.server.tsx
@@ -0,0 +1,74 @@
+import type { ComponentProps } from "react";
+import { it, expect, describe } from "vitest";
+import { renderToString } from "react-dom/server";
+import { EvoDetails } from "../details";
+import { EvoDetailsSummary } from "../details-summary";
+import { EvoDetailsLabel } from "../details-label";
+import { EvoDetailsLeading } from "../details-leading";
+import { EvoDetailsContent } from "../details-content";
+import { EvoIconLightbulb16 } from "../../evo-icon/icons/evo-icon-lightbulb-16";
+import type { Size, Alignment } from "../types";
+
+function Details(props: Partial> = {}) {
+ return (
+
+
+ Show me the details!
+
+ Content
+
+ );
+}
+
+describe("EvoDetails SSR", () => {
+ it("renders default state", () => {
+ expect(renderToString( )).toMatchSnapshot();
+ });
+
+ it("renders with open=true", () => {
+ expect(renderToString( )).toMatchSnapshot();
+ });
+
+ it.each(["regular", "small"])(
+ "renders with size=%s",
+ (size) => {
+ expect(renderToString( )).toMatchSnapshot();
+ },
+ );
+
+ it.each(["regular", "center"])(
+ "renders with alignment=%s",
+ (alignment) => {
+ expect(renderToString( )).toMatchSnapshot();
+ },
+ );
+
+ it("renders with leading element", () => {
+ expect(
+ renderToString(
+
+
+
+
+
+ Show me the details!
+
+ Content
+ ,
+ ),
+ ).toMatchSnapshot();
+ });
+
+ it("renders with custom content element via `as`", () => {
+ expect(
+ renderToString(
+
+
+ Details
+
+ Paragraph content
+ ,
+ ),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/packages/evo-react/src/evo-details/types.ts b/packages/evo-react/src/evo-details/types.ts
new file mode 100644
index 0000000000..6fa8886266
--- /dev/null
+++ b/packages/evo-react/src/evo-details/types.ts
@@ -0,0 +1,24 @@
+import type { ComponentProps, ElementType, ReactNode, SyntheticEvent } from "react";
+
+export type Size = "regular" | "small";
+export type Alignment = "regular" | "center";
+
+export type EvoDetailsProps = Omit, "onToggle"> & {
+ size?: Size;
+ alignment?: Alignment;
+ onToggle?: (
+ event: SyntheticEvent,
+ data: { open: boolean },
+ ) => void;
+};
+
+export type EvoDetailsSummaryProps = ComponentProps<"summary">;
+
+export type EvoDetailsLeadingProps = ComponentProps<"span">;
+
+export type EvoDetailsLabelProps = ComponentProps<"span">;
+
+export type EvoDetailsContentProps = ComponentProps<"div"> & {
+ as?: ElementType;
+ children?: ReactNode;
+};
diff --git a/packages/evo-react/src/index.ts b/packages/evo-react/src/index.ts
index c4c1106a22..99425863c4 100644
--- a/packages/evo-react/src/index.ts
+++ b/packages/evo-react/src/index.ts
@@ -10,3 +10,18 @@ export type {
export { EvoIconProvider, IconContext } from "./evo-icon";
export type { A11yVariant } from "./evo-icon";
+
+export {
+ EvoDetails,
+ EvoDetailsSummary,
+ EvoDetailsLeading,
+ EvoDetailsLabel,
+ EvoDetailsContent,
+} from "./evo-details";
+export type {
+ EvoDetailsProps,
+ EvoDetailsSummaryProps,
+ EvoDetailsLeadingProps,
+ EvoDetailsLabelProps,
+ EvoDetailsContentProps,
+} from "./evo-details";