diff --git a/.changeset/lucky-terms-sing.md b/.changeset/lucky-terms-sing.md
new file mode 100644
index 00000000000..c9fc974e679
--- /dev/null
+++ b/.changeset/lucky-terms-sing.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Card: Add `data-component` attributes to `Card` and its subcomponents (`Icon`, `Image`, `Heading`, `Description`, `Metadata`, `Menu`). Add an `as` prop (`'div' | 'section'`) so standalone Cards can render as a labelled region landmark; `as="section"` requires `aria-label` or `aria-labelledby`. `Card` now requires `children`. Also improves docs and stories.
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-colorblind-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-colorblind-linux.png
index 9d513c74652..91f0540bf99 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-colorblind-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-colorblind-linux.png differ
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-dimmed-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-dimmed-linux.png
index 692774fea4f..d527154a961 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-dimmed-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-dimmed-linux.png differ
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-high-contrast-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-high-contrast-linux.png
index ac8caa19164..97b47c0bc02 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-high-contrast-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-high-contrast-linux.png differ
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-linux.png
index 9d513c74652..91f0540bf99 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-linux.png differ
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-tritanopia-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-tritanopia-linux.png
index 9d513c74652..91f0540bf99 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-tritanopia-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-dark-tritanopia-linux.png differ
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-colorblind-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-colorblind-linux.png
index 171b4a01008..5be730c7196 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-colorblind-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-colorblind-linux.png differ
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-high-contrast-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-high-contrast-linux.png
index 501b37ef7e1..65f7e72ad59 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-high-contrast-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-high-contrast-linux.png differ
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-linux.png
index 171b4a01008..5be730c7196 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-linux.png differ
diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-tritanopia-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-tritanopia-linux.png
index 171b4a01008..5be730c7196 100644
Binary files a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-tritanopia-linux.png and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-tritanopia-linux.png differ
diff --git a/packages/react/src/Card/Card.docs.json b/packages/react/src/Card/Card.docs.json
index 60805a2a823..04fff6032f7 100644
--- a/packages/react/src/Card/Card.docs.json
+++ b/packages/react/src/Card/Card.docs.json
@@ -13,9 +13,27 @@
},
{
"id": "experimental-components-card-features--with-metadata"
+ },
+ {
+ "id": "experimental-components-card-features--with-menu"
+ },
+ {
+ "id": "experimental-components-card-features--standalone-section"
+ },
+ {
+ "id": "experimental-components-card-features--in-list"
+ },
+ {
+ "id": "experimental-components-card-features--interactive-content"
}
],
"props": [
+ {
+ "name": "children",
+ "type": "React.ReactNode",
+ "required": true,
+ "description": "The contents of the card. Provide either `Card.*` subcomponents (for example `Card.Heading`, `Card.Description`, `Card.Metadata`) or any custom content. A card with no children will not render."
+ },
{
"name": "className",
"type": "string",
@@ -32,6 +50,12 @@
"type": "'medium' | 'large'",
"defaultValue": "'large'",
"description": "Controls the border radius of the Card."
+ },
+ {
+ "name": "as",
+ "type": "'div' | 'section'",
+ "defaultValue": "'div'",
+ "description": "The HTML element to render. Use `'section'` for **standalone** Cards (not inside a list of cards) so screen readers announce the Card as a labelled region; in that case, either `aria-label` or `aria-labelledby` is required. Use the default `'div'` (and omit `Card.Heading`) when the Card is inside an `
` of a list of cards — the surrounding list already provides grouping."
}
],
"subcomponents": [
@@ -73,21 +97,42 @@
"name": "as",
"type": "'h2' | 'h3' | 'h4' | 'h5' | 'h6'",
"defaultValue": "'h3'",
- "description": "The heading level to render."
+ "description": "The heading level to render. Use on standalone Cards (with `as=\"section\"` on the parent `Card`) only; do not use `Card.Heading` when the Card is inside an `
` of a list of cards."
}
]
},
{
"name": "Card.Description",
- "props": []
+ "props": [
+ {
+ "name": "children",
+ "type": "React.ReactNode",
+ "required": true,
+ "description": "The descriptive text for the card. Rendered inside a `
` element so should be flowing text content."
+ }
+ ]
},
{
"name": "Card.Menu",
- "props": []
+ "props": [
+ {
+ "name": "children",
+ "type": "React.ReactNode",
+ "required": true,
+ "description": "The interactive control(s) to render in the top-right corner of the card, typically a single `IconButton` or `ActionMenu` trigger. When a card contains a menu, make sure the control's accessible name includes enough context to distinguish it from other cards (for example, 'Options for Project Alpha' rather than just 'Options')."
+ }
+ ]
},
{
"name": "Card.Metadata",
- "props": []
+ "props": [
+ {
+ "name": "children",
+ "type": "React.ReactNode",
+ "required": true,
+ "description": "The metadata content to render at the bottom of the card. Accepts any content, including plain text, icons, and other Primer components (for example a `Label`, `Octicon`, or any combination). Avoid using `RelativeTime` until its outstanding accessibility issues are resolved."
+ }
+ ]
}
]
}
diff --git a/packages/react/src/Card/Card.features.stories.tsx b/packages/react/src/Card/Card.features.stories.tsx
index 67e664489c8..e9287832925 100644
--- a/packages/react/src/Card/Card.features.stories.tsx
+++ b/packages/react/src/Card/Card.features.stories.tsx
@@ -1,56 +1,164 @@
import type {Meta} from '@storybook/react-vite'
-import {RepoIcon, StarIcon} from '@primer/octicons-react'
+import {KebabHorizontalIcon, RepoIcon, RepoForkedIcon, StarIcon} from '@primer/octicons-react'
+import {ActionList, ActionMenu, Button, IconButton, VisuallyHidden} from '..'
import {Card} from './index'
+import classes from './Card.stories.module.css'
const meta = {
title: 'Experimental/Components/Card/Features',
component: Card,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
} satisfies Meta
export default meta
export const WithImage = () => {
return (
-
-
-
- Card with Image
- This card uses an edge-to-edge image instead of an icon.
-
-
+
+
+ Card with Image
+ This card uses an edge-to-edge image instead of an icon.
+
)
}
export const WithMetadata = () => {
return (
-
+
+
+ primer/react
+
+ {"GitHub's design system implemented as React components for building consistent user interfaces."}
+
+
+
+ 1.2k stars
+
+
+ )
+}
+
+export const WithMenu = () => {
+ return (
+
+
+ primer/react
+
+ {"GitHub's design system implemented as React components for building consistent user interfaces."}
+
+
+
+
+
+
+
+
+ Star
+ Watch
+ Fork
+
+
+
+
+
+ )
+}
+
+export const CustomContent = () => (
+
+
+
Custom Content Card
+
This card uses arbitrary custom content instead of the built-in subcomponents.
+
+
Item one
+
Item two
+
Item three
+
+
+
+)
+
+export const StandaloneSection = () => (
+
+
+ primer/react
+
+ {
+ "Standalone cards render as a labelled landmark. The Card.Heading's id is referenced via aria-labelledby so screen readers announce the heading as the section's accessible name."
+ }
+
+
+)
+
+export const InList = () => (
+
+
- primer/react
-
- {"GitHub's design system implemented as React components for building consistent user interfaces."}
-
+ primer/react
1.2k stars
-
+
+
+
+
+ primer/css
+
+
+ 850 stars
+
+
+
+
+
+
+ primer/octicons
+
+
+ 2.1k stars
+
+
+
+
+)
+
+/**
+ * When several Cards share the same interactive controls (for example "Star"
+ * or "Fork" buttons in a list of repositories), the controls' accessible
+ * names must include enough context to distinguish one card's action from
+ * another's. This story uses `VisuallyHidden` to append the repo name to
+ * each button's accessible name — a common pattern across GitHub.
+ */
+export const InteractiveContent = () => {
+ const repos = [{name: 'primer/react'}, {name: 'primer/css'}, {name: 'primer/octicons'}]
+
+ return (
+
+ {repos.map(repo => (
+
+
+
+ {repo.name}
+
+
+
+
+
+
+ ))}
+
)
}
-
-export const CustomContent = () => (
-
-
-
- Custom Content Card
-
This card uses arbitrary custom content instead of the built-in subcomponents.
+ ),
+ ],
} satisfies Meta
export default meta
export const Default = () => {
return (
-
-
-
- Card Heading
- This is a description of the card providing supplemental information.
- Updated 2 hours ago
-
-
+
+
+ Card Heading
+ This is a description of the card providing supplemental information.
+
+ 3 contributors
+
+
)
}
@@ -30,14 +38,12 @@ type PlaygroundArgs = {
}
export const Playground: StoryFn = ({showIcon, showMetadata, padding, borderRadius}) => (
-
-
- {showIcon && }
- Playground Card
- Experiment with the Card component and its subcomponents.
- {showMetadata && Just now}
-
-
+
+ {showIcon && }
+ Playground Card
+ Experiment with the Card component and its subcomponents.
+ {showMetadata && Just now}
+
)
Playground.args = {
diff --git a/packages/react/src/Card/Card.test.tsx b/packages/react/src/Card/Card.test.tsx
index 817459d4766..619dab355c7 100644
--- a/packages/react/src/Card/Card.test.tsx
+++ b/packages/react/src/Card/Card.test.tsx
@@ -1,4 +1,4 @@
-import {describe, expect, it} from 'vitest'
+import {describe, expect, it, vi} from 'vitest'
import {render, screen} from '@testing-library/react'
import {Card} from '../Card'
import {implementsClassName} from '../utils/testing'
@@ -7,7 +7,14 @@ import classes from './Card.module.css'
const TestIcon = () =>
describe('Card', () => {
- implementsClassName(props => , classes.Card)
+ implementsClassName(
+ props => (
+
+ Card Heading
+
+ ),
+ classes.Card,
+ )
it('should render a Card with heading and description', () => {
render(
@@ -171,4 +178,128 @@ describe('Card', () => {
)
expect(container.firstChild).toHaveAttribute('data-border-radius', 'medium')
})
+
+ it('should set data-component attributes on Card and its subcomponents', () => {
+ const {container} = render(
+
+
+ With data-component
+ Description text
+ Metadata text
+
+
+
+ ,
+ )
+ expect(container.querySelector('[data-component="Card"]')).toBeInTheDocument()
+ expect(container.querySelector('[data-component="Card.Icon"]')).toBeInTheDocument()
+ expect(container.querySelector('[data-component="Card.Heading"]')).toBeInTheDocument()
+ expect(container.querySelector('[data-component="Card.Description"]')).toBeInTheDocument()
+ expect(container.querySelector('[data-component="Card.Metadata"]')).toBeInTheDocument()
+ expect(container.querySelector('[data-component="Card.Menu"]')).toBeInTheDocument()
+ })
+
+ it('should set data-component="Card.Image" on Card.Image', () => {
+ const {container} = render(
+
+
+ With Image
+ ,
+ )
+ expect(container.querySelector('[data-component="Card.Image"]')).toBeInTheDocument()
+ })
+
+ it('should set data-component="Card" on custom content cards', () => {
+ const {container} = render(
+
+
Custom
+ ,
+ )
+ expect(container.querySelector('[data-component="Card"]')).toBeInTheDocument()
+ })
+
+ it('should not render when there are no children', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ // @ts-expect-error - children is required, but we want to verify the runtime behaviour
+ const {container} = render()
+ expect(container).toBeEmptyDOMElement()
+ consoleSpy.mockRestore()
+ })
+
+ it('should not render when all children are falsy', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const {container} = render({false})
+ expect(container).toBeEmptyDOMElement()
+ consoleSpy.mockRestore()
+ })
+
+ it('should warn in development when rendered with no children', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ // @ts-expect-error - children is required, but we want to verify the dev warning
+ render()
+ expect(consoleSpy).toHaveBeenCalledWith('Warning:', expect.stringContaining('was rendered with no children'))
+ consoleSpy.mockRestore()
+ })
+
+ it('should render as a
by default', () => {
+ const {container} = render(
+
+ Default Element
+ ,
+ )
+ expect(container.firstChild?.nodeName).toBe('DIV')
+ })
+
+ it('should render as a when as="section"', () => {
+ const {container} = render(
+
+ Standalone
+ This card is standalone.
+ ,
+ )
+ expect(container.firstChild?.nodeName).toBe('SECTION')
+ expect(container.firstChild).toHaveAttribute('aria-label', 'Standalone card')
+ })
+
+ it('should expose the section as a labelled region landmark', () => {
+ render(
+
+ Standalone
+ This card is standalone.
+ ,
+ )
+ expect(screen.getByRole('region', {name: 'Standalone'})).toBeInTheDocument()
+ })
+
+ it('should warn in development when as="section" is used without an accessible name', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ render(
+ // @ts-expect-error - aria-label or aria-labelledby is required, but we want to verify the dev warning
+
+ No accessible name
+ ,
+ )
+ expect(consoleSpy).toHaveBeenCalledWith('Warning:', expect.stringContaining('requires either `aria-label`'))
+ consoleSpy.mockRestore()
+ })
+
+ it('should not warn when as="section" is used with aria-label', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ render(
+
+ Heading
+ ,
+ )
+ expect(consoleSpy).not.toHaveBeenCalled()
+ consoleSpy.mockRestore()
+ })
+
+ it('should not forward the `as` prop to the DOM', () => {
+ const {container} = render(
+
+ Heading
+ ,
+ )
+ expect(container.firstChild).not.toHaveAttribute('as')
+ })
})
diff --git a/packages/react/src/Card/Card.tsx b/packages/react/src/Card/Card.tsx
index c6f81ffb311..e30d2e7e601 100644
--- a/packages/react/src/Card/Card.tsx
+++ b/packages/react/src/Card/Card.tsx
@@ -1,11 +1,11 @@
import {clsx} from 'clsx'
import React, {forwardRef} from 'react'
import classes from './Card.module.css'
+import {warning} from '../utils/warning'
-export type CardProps = React.ComponentPropsWithoutRef<'div'> & {
+type BaseCardProps = Omit, 'children'> & {
/**
- * Provide an optional className to add to the outermost element rendered by
- * the Card
+ * Optional className for the root element.
*/
className?: string
@@ -20,8 +20,36 @@ export type CardProps = React.ComponentPropsWithoutRef<'div'> & {
* @default 'large'
*/
borderRadius?: 'medium' | 'large'
+
+ /**
+ * The card contents. Provide either `Card.*` subcomponents (e.g.
+ * `Card.Heading`, `Card.Description`, `Card.Metadata`) or custom content.
+ * Empty cards do not render.
+ */
+ children: React.ReactNode
}
+/**
+ * The default Card. Use this inside `
` when rendering a list of cards:
+ * the list already groups them, so the Card itself doesn't need to be a
+ * landmark. Don't use `Card.Heading` here.
+ */
+type DivCardProps = BaseCardProps & {
+ as?: 'div'
+}
+
+/**
+ * Renders the Card as a ``. Use this for a standalone Card that
+ * isn't part of a list. The `` is a region landmark, so it needs
+ * an accessible name via `aria-label` or `aria-labelledby` (the latter
+ * usually points at `Card.Heading`).
+ */
+type SectionCardProps = BaseCardProps & {
+ as: 'section'
+} & ({'aria-label': string} | {'aria-labelledby': string})
+
+export type CardProps = DivCardProps | SectionCardProps
+
type HeadingLevel = 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
type HeadingProps = React.ComponentPropsWithoutRef<'h3'> & {
@@ -33,6 +61,9 @@ type HeadingProps = React.ComponentPropsWithoutRef<'h3'> & {
}
type DescriptionProps = React.ComponentPropsWithoutRef<'p'> & {
+ /**
+ * Card description. Rendered as a `
`, so keep it to flowing text.
+ */
children: React.ReactNode
}
@@ -60,17 +91,30 @@ type ImageProps = React.ComponentPropsWithoutRef<'img'> & {
}
type MenuProps = {
+ /**
+ * Interactive control in the top-right of the card, usually an `IconButton`
+ * or `ActionMenu` trigger. Give the control a label that names the card
+ * (e.g. `"Options for Project Alpha"`, not just `"Options"`) so it's
+ * distinguishable when several cards are on screen.
+ */
children: React.ReactNode
}
type MetadataProps = React.ComponentPropsWithoutRef<'div'> & {
+ /**
+ * Metadata row at the bottom of the card. Any content works: text, icons,
+ * a `Label`, an `Octicon`. Skip `RelativeTime` for now; it has open
+ * accessibility issues.
+ */
children: React.ReactNode
}
-const CardImpl = forwardRef(function Card(
- {children, className, padding = 'normal', borderRadius = 'large', ...rest},
- ref,
-) {
+const CardImpl = forwardRef(function Card(props, ref) {
+ const {children, className, padding = 'normal', borderRadius = 'large', as = 'div', ...rest} = props
+ // Use ElementType so JSX doesn't intersect the intrinsic prop types of
+ // `div` and `section` (which would pin the ref back to `HTMLDivElement`).
+ const Component = as as React.ElementType
+
let icon: React.ReactNode = null
let image: React.ReactNode = null
let heading: React.ReactNode = null
@@ -100,24 +144,44 @@ const CardImpl = forwardRef(function Card(
const hasSlotChildren = icon || image || heading || description || metadata || menu
+ // `React.Children.toArray` already drops `null`/`undefined`/`false`/`true`,
+ // so an empty array means we have nothing to render.
+ const isEmpty = !hasSlotChildren && childArray.length === 0
+
+ warning(
+ isEmpty,
+ 'The component was rendered with no children and will not render. Provide either Card subcomponents (Card.Heading, Card.Description, etc.) or custom content.',
+ )
+
+ warning(
+ as === 'section' && !('aria-label' in props) && !('aria-labelledby' in props),
+ 'The component used with `as="section"` requires either `aria-label` or `aria-labelledby` so screen-reader users can identify the labelled region. Typically `aria-labelledby` should reference the id of the `Card.Heading`.',
+ )
+
+ if (isEmpty) {
+ return null
+ }
+
if (!hasSlotChildren) {
return (
-
{children}
-
+
)
}
return (
-
(function Card(
{metadata ?
{metadata}
: null}
{menu ?
{menu}
: null}
-
+
)
})
@@ -141,6 +205,7 @@ const CardIcon = ({icon: IconComponent, 'aria-label': ariaLabel, className}: Ico
return (
{
- return
+ return (
+
+ )
}
CardImage.displayName = 'Card.Image'
+/**
+ * Heading shown at the top of a Card.
+ *
+ * Only use this on a standalone Card (`as="section"`); don't use it inside
+ * an `
`, where the surrounding list already provides grouping.
+ *
+ * Give it an `id` and reference that id from the parent Card's
+ * `aria-labelledby` so the section landmark uses the heading as its
+ * accessible name.
+ */
const CardHeading = forwardRef(function CardHeading(
{as: Component = 'h3', children, className, ...rest},
ref,
) {
return (
-
+
{children}
)
@@ -174,14 +251,25 @@ const CardDescription = forwardRef(funct
ref,
) {
return (
-
+
{children}
)
})
+/**
+ * Top-right slot for a single interactive control, usually an `IconButton`
+ * or `ActionMenu` trigger.
+ *
+ * Give the control a label that names the card (e.g. `"More options for
+ * Project Alpha"`, not just `"More options"`) so users can tell which card
+ * the action applies to when several cards are visible.
+ *
+ * For more than one action, put them inside an `ActionMenu` rather than
+ * cramming multiple controls into `Card.Menu`.
+ */
const CardMenu = ({children}: MenuProps) => {
- return <>{children}>
+ return