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/add-evo-details-leading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evo-web/marko": patch
---

Add leading attribute tag to evo-details
5 changes: 5 additions & 0 deletions .changeset/add-evo-details-react.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evo-web/react": patch
---

Add evo-details component
Comment thread
HenriqueLimas marked this conversation as resolved.
54 changes: 54 additions & 0 deletions .claude/skills/evo-app-migrate-react/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This list is going to get really long once everything's in here... maybe we should have a separate markdown file for each component?

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.

Yeah probably we should start moving around.


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";

<EbayDetails
text="Show me the details!"
size="small"
alignment="center"
leading={<Icon />}
onToggle={handler}
>
Content here
</EbayDetails>;
```

**After:**

```tsx
import {
EvoDetails,
EvoDetailsSummary,
EvoDetailsLeading,
EvoDetailsLabel,
EvoDetailsContent,
} from "@evo-web/react";

<EvoDetails size="small" alignment="center" onToggle={handler}>
<EvoDetailsSummary>
<EvoDetailsLeading>
<Icon />
</EvoDetailsLeading>
<EvoDetailsLabel>Show me the details!</EvoDetailsLabel>
</EvoDetailsSummary>
<EvoDetailsContent>Content here</EvoDetailsContent>
</EvoDetails>;
```

**Prop changes:**

| ebayui-core-react | evo-react | Notes |
| ------------------------ | ----------------------------------- | ---------------------------------------------------------------------------- |
| `text: string` | `<EvoDetailsLabel>` sub-component | Move label text into `<EvoDetailsLabel>` inside `<EvoDetailsSummary>` |
| `leading?: ReactElement` | `<EvoDetailsLeading>` sub-component | Move leading element into `<EvoDetailsLeading>` inside `<EvoDetailsSummary>` |
| `as?: ElementType` | `as` prop on `<EvoDetailsContent>` | Move to the content sub-component |
| `onToggle` | `onToggle` | Same signature: `(event, { open: boolean }) => void` |
| `children` | `<EvoDetailsContent>` children | Wrap children in `<EvoDetailsContent>` |

**Important:** `<EvoDetailsLeading>` must appear before `<EvoDetailsLabel>` inside `<EvoDetailsSummary>` — order is not enforced by the component.

---

## Step 4 — Verify
Expand Down
32 changes: 27 additions & 5 deletions .claude/skills/evo-migrate-react/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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
Expand Down Expand Up @@ -129,6 +147,8 @@ import { EvoIconChevronDown16 } from "../evo-icon/icons/evo-icon-chevron-down-16
<EbayIcon name="chevron-down-16" />
```

This applies to **stories and tests** as well — never use inline `<svg>` 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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
48 changes: 48 additions & 0 deletions docs/adr/0005-evo-react-child-component-composition.md
Original file line number Diff line number Diff line change
@@ -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={<span />}`** — 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
<EvoDetails size="small" onToggle={handler}>
<EvoDetailsSummary>
<EvoDetailsLeading>
<EvoIconLightbulb16 />
</EvoDetailsLeading>
<EvoDetailsLabel>How do I get started?</EvoDetailsLabel>
</EvoDetailsSummary>
<EvoDetailsContent>Content</EvoDetailsContent>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are we planning on always having *Content tags? I'd hope that in many cases we can just keep the content inline

<EvoDetails size="small" onToggle={handler}>
  <EvoDetailsSummary>
    <EvoDetailsLeading>
      <EvoIconLightbulb16 />
    </EvoDetailsLeading>
    How do I get started?
  </EvoDetailsSummary>
  Content
</EvoDetails>

Copy link
Copy Markdown
Member Author

@HenriqueLimas HenriqueLimas Apr 24, 2026

Choose a reason for hiding this comment

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

Not always, it will depend on the component and how skin organizes it. Having the component allow the developer to add other properties like class names to the container, but it will depend for each component might be different.

</EvoDetails>
Comment on lines +23 to +31
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thoughts on using an object instead of individual components, like Radix does? Might make more sense for grouping, but I'm not sure

<EvoDetails.Root size="small" onToggle={handler}>
  <EvoDetails.Summary>
    <EvoDetails.Leading>
      <EvoIconLightbulb16 />
    </EvoDetails.Leading>
    <EvoDetails.Label>How do I get started?</EvoDetails.Label>
  </EvoDetails.Summary>
  <EvoDetails.Content>Content</EvoDetails.Content>
</EvoDetails.Root>

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.

We can have both at some point like base-ui has.

```

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
16 changes: 16 additions & 0 deletions packages/evo-marko/src/tags/evo-details/details.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,6 +20,15 @@ export default {

argTypes: {
content: {},
leading: {
description: "Optional leading element (e.g. an icon) rendered before the summary label",
"@": {
["<span> attributes" as any]: {
description:
"All attributes and event handlers from [the native HTML `<span>` 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",
"@": {
Expand Down Expand Up @@ -63,3 +74,8 @@ export const Default = buildExtensionTemplate(
DefaultTemplate,
DefaultTemplateCode,
);

export const WithLeading = buildExtensionTemplate(
WithLeadingTemplate,
WithLeadingTemplateCode,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<evo-details ...input>
<@summary>Details</@summary>
<@leading><evo-icon-lightbulb-16/></@leading>
Comment on lines +2 to +3
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we nest <@leading> inside <@summary> instead? It'll require a <const/{ leading, ...rest }=summary/> on the consuming side but I think that's worth it

Suggested change
<@summary>Details</@summary>
<@leading><evo-icon-lightbulb-16/></@leading>
<@summary>
<@leading><evo-icon-lightbulb-16/></@leading>
Details
</@summary>

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.

This will be wrong no? It will render the icon inside the summary text? But it is a sibling

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In the markup it is a child of <summary>, not a sibling...

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, I guess you're right but it does mess with my mental model a little bit. I've also never been a fan of @leading as a name, maybe that's part of it. How about leadingIcon?

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.
</evo-details>
5 changes: 5 additions & 0 deletions packages/evo-marko/src/tags/evo-details/index.marko
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface Input extends Marko.Input<"details"> {
summary?: Marko.AttrTag<Marko.Input<"span">>;
leading?: Marko.AttrTag<Marko.Input<"span">>;
size?: "regular" | "small";
alignment?: "regular" | "center";
contentAs?: keyof Marko.NativeTags;
Expand All @@ -12,6 +13,7 @@ export interface Input extends Marko.Input<"details"> {
alignment,
size,
summary,
leading,
content,
contentAs,
...htmlInput
Expand All @@ -24,6 +26,9 @@ export interface Input extends Marko.Input<"details"> {
size === "small" && "details__summary--small",
alignment === "center" && "details__summary--center",
]>
<if=leading>
<span ...leading class=["details__leading", leading.class]/>
</if>
<span ...summary class=["details__label", summary.class]/>
<span class="details__icon" hidden>
<evo-icon-chevron-down-16/>
Expand Down
6 changes: 5 additions & 1 deletion packages/evo-marko/src/tags/evo-details/test/test.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
});
});
5 changes: 5 additions & 0 deletions packages/evo-react/src/evo-details/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# EvoDetails

## Documentation

[Storybook](https://opensource.ebay.com/evo-web/react/main/?path=/docs/navigation-disclosure-evo-details--documentation)
13 changes: 13 additions & 0 deletions packages/evo-react/src/evo-details/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext, useContext } from "react";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If it was up to me I'd probably put all of the component parts in one file, you know better than I do though

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.

I think having in a separate file prevents the risk of circular dependencies since we have multiple components.

import type { Size, Alignment } from "./types";

export type DetailsContextValue = {
size?: Size;
alignment?: Alignment;
};

export const DetailsContext = createContext<DetailsContextValue>({});

export function useDetailsContext() {
return useContext(DetailsContext);
}
15 changes: 15 additions & 0 deletions packages/evo-react/src/evo-details/details-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import classNames from "classnames";
import type { EvoDetailsContentProps } from "./types";

export function EvoDetailsContent({
as: Component = "div",
children,
className,
...rest
}: EvoDetailsContentProps) {
return (
<Component className={classNames("details__content", className)} {...rest}>
{children}
</Component>
);
}
14 changes: 14 additions & 0 deletions packages/evo-react/src/evo-details/details-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import classNames from "classnames";
import type { EvoDetailsLabelProps } from "./types";

export function EvoDetailsLabel({
children,
className,
...rest
}: EvoDetailsLabelProps) {
return (
<span className={classNames("details__label", className)} {...rest}>
{children}
</span>
);
}
14 changes: 14 additions & 0 deletions packages/evo-react/src/evo-details/details-leading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import classNames from "classnames";
import type { EvoDetailsLeadingProps } from "./types";

export function EvoDetailsLeading({
children,
className,
...rest
}: EvoDetailsLeadingProps) {
return (
<span className={classNames("details__leading", className)} {...rest}>
{children}
</span>
);
}
29 changes: 29 additions & 0 deletions packages/evo-react/src/evo-details/details-summary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<summary
className={classNames(
"details__summary",
size === "small" && "details__summary--small",
alignment === "center" && "details__summary--center",
className,
)}
{...rest}
>
{children}
<span className="details__icon" hidden>
<EvoIconChevronDown16 />
</span>
</summary>
);
}
Loading
Loading