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 ( + ) } - -export const CustomContent = () => ( -
    - -
    - Custom Content Card -

    This card uses arbitrary custom content instead of the built-in subcomponents.

    -
      -
    • Item one
    • -
    • Item two
    • -
    • Item three
    • -
    -
    -
    -
    -) diff --git a/packages/react/src/Card/Card.stories.module.css b/packages/react/src/Card/Card.stories.module.css new file mode 100644 index 00000000000..bd3ff8acdeb --- /dev/null +++ b/packages/react/src/Card/Card.stories.module.css @@ -0,0 +1,28 @@ +.WidthConstraintContainer { + max-width: 400px; +} + +.CustomContentLayout { + display: flex; + flex-direction: column; + gap: var(--stack-gap-condensed); +} + +.CustomContentLayout > h3, +.CustomContentLayout > p, +.CustomContentLayout > ul { + margin: 0; +} + +.CustomContentLayout > ul { + padding-inline-start: var(--base-size-16); +} + +.CardList { + display: flex; + flex-direction: column; + gap: var(--stack-gap-condensed); + margin: 0; + padding: 0; + list-style: none; +} diff --git a/packages/react/src/Card/Card.stories.tsx b/packages/react/src/Card/Card.stories.tsx index 692ddde46a0..3f0f9d30e54 100644 --- a/packages/react/src/Card/Card.stories.tsx +++ b/packages/react/src/Card/Card.stories.tsx @@ -1,24 +1,32 @@ import type {Meta, StoryFn} from '@storybook/react-vite' -import {RocketIcon} from '@primer/octicons-react' +import {PeopleIcon, RocketIcon} from '@primer/octicons-react' import {Card} from './index' +import classes from './Card.stories.module.css' const meta = { title: 'Experimental/Components/Card', component: Card, + decorators: [ + Story => ( +
    + +
    + ), + ], } 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 {alt} + return ( + {alt} + ) } 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
    {children}
    } CardMenu.displayName = 'Card.Menu' @@ -191,7 +279,7 @@ const CardMetadata = forwardRef(function CardMeta ref, ) { return ( -
    +
    {children}
    )