diff --git a/.changeset/add-card-component.md b/.changeset/add-card-component.md new file mode 100644 index 00000000000..5e6917a58f0 --- /dev/null +++ b/.changeset/add-card-component.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add Card component with subcomponents: Card.Icon, Card.Image, Card.Heading, Card.Description, Card.Menu, and Card.Metadata 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 new file mode 100644 index 00000000000..9d513c74652 Binary files /dev/null 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 new file mode 100644 index 00000000000..692774fea4f Binary files /dev/null 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 new file mode 100644 index 00000000000..ac8caa19164 Binary files /dev/null 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 new file mode 100644 index 00000000000..9d513c74652 Binary files /dev/null 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 new file mode 100644 index 00000000000..9d513c74652 Binary files /dev/null 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 new file mode 100644 index 00000000000..171b4a01008 Binary files /dev/null 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 new file mode 100644 index 00000000000..501b37ef7e1 Binary files /dev/null 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 new file mode 100644 index 00000000000..171b4a01008 Binary files /dev/null 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 new file mode 100644 index 00000000000..171b4a01008 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-Default-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-colorblind-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-colorblind-linux.png new file mode 100644 index 00000000000..a97c5a601a1 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-dimmed-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-dimmed-linux.png new file mode 100644 index 00000000000..8006e1d1b0f Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-high-contrast-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-high-contrast-linux.png new file mode 100644 index 00000000000..5945b1e9c8b Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-linux.png new file mode 100644 index 00000000000..a97c5a601a1 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-tritanopia-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-tritanopia-linux.png new file mode 100644 index 00000000000..a97c5a601a1 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-colorblind-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-colorblind-linux.png new file mode 100644 index 00000000000..566e499e531 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-high-contrast-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-high-contrast-linux.png new file mode 100644 index 00000000000..a7955375071 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-linux.png new file mode 100644 index 00000000000..566e499e531 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-tritanopia-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-tritanopia-linux.png new file mode 100644 index 00000000000..566e499e531 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Image-light-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-colorblind-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-colorblind-linux.png new file mode 100644 index 00000000000..2c95e8e73af Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-dimmed-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-dimmed-linux.png new file mode 100644 index 00000000000..d2163d1b387 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-dimmed-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-high-contrast-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-high-contrast-linux.png new file mode 100644 index 00000000000..4696164a49f Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-linux.png new file mode 100644 index 00000000000..2c95e8e73af Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-tritanopia-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-tritanopia-linux.png new file mode 100644 index 00000000000..2c95e8e73af Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-dark-tritanopia-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-colorblind-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-colorblind-linux.png new file mode 100644 index 00000000000..d328d643452 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-colorblind-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-high-contrast-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-high-contrast-linux.png new file mode 100644 index 00000000000..8f2efddc18d Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-linux.png new file mode 100644 index 00000000000..d328d643452 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-linux.png differ diff --git a/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-tritanopia-linux.png b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-tritanopia-linux.png new file mode 100644 index 00000000000..d328d643452 Binary files /dev/null and b/.playwright/snapshots/components/Card.test.ts-snapshots/Card-With-Metadata-light-tritanopia-linux.png differ diff --git a/e2e/components/Card.test.ts b/e2e/components/Card.test.ts new file mode 100644 index 00000000000..8c4e77acfde --- /dev/null +++ b/e2e/components/Card.test.ts @@ -0,0 +1,59 @@ +import {test, expect} from '@playwright/test' +import {visit} from '../test-helpers/storybook' +import {themes} from '../test-helpers/themes' + +test.describe('Card', () => { + test.describe('Default', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'experimental-components-card--default', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`Card.Default.${theme}.png`) + }) + }) + } + }) + + test.describe('With Image', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'experimental-components-card-features--with-image', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`Card.With Image.${theme}.png`) + }) + }) + } + }) + + test.describe('With Metadata', () => { + for (const theme of themes) { + test.describe(theme, () => { + test('default @vrt', async ({page}) => { + await visit(page, { + id: 'experimental-components-card-features--with-metadata', + globals: { + colorScheme: theme, + }, + }) + + // Default state + expect(await page.screenshot()).toMatchSnapshot(`Card.With Metadata.${theme}.png`) + }) + }) + } + }) +}) diff --git a/packages/react/src/Card/Card.docs.json b/packages/react/src/Card/Card.docs.json new file mode 100644 index 00000000000..eb9394b272b --- /dev/null +++ b/packages/react/src/Card/Card.docs.json @@ -0,0 +1,87 @@ +{ + "id": "card", + "name": "Card", + "status": "draft", + "a11yReviewed": false, + "importPath": "@primer/react/experimental", + "stories": [ + { + "id": "experimental-components-card--default" + }, + { + "id": "experimental-components-card-features--with-image" + }, + { + "id": "experimental-components-card-features--with-metadata" + } + ], + "props": [ + { + "name": "className", + "type": "string", + "description": "CSS class name(s) for custom styling." + }, + { + "name": "padding", + "type": "'none' | 'condensed' | 'normal'", + "defaultValue": "'normal'", + "description": "Controls the internal padding of the Card." + } + ], + "subcomponents": [ + { + "name": "Card.Icon", + "props": [ + { + "name": "icon", + "type": "React.ElementType", + "description": "An Octicon or custom SVG icon to render." + }, + { + "name": "aria-label", + "type": "string", + "description": "Accessible label for the icon. When omitted, the icon is treated as decorative." + } + ] + }, + { + "name": "Card.Image", + "props": [ + { + "name": "src", + "type": "string", + "description": "The image source URL." + }, + { + "name": "alt", + "type": "string", + "defaultValue": "\"\"", + "description": "Alt text for accessibility. Defaults to empty string (decorative)." + } + ] + }, + { + "name": "Card.Heading", + "props": [ + { + "name": "as", + "type": "'h2' | 'h3' | 'h4' | 'h5' | 'h6'", + "defaultValue": "'h3'", + "description": "The heading level to render." + } + ] + }, + { + "name": "Card.Description", + "props": [] + }, + { + "name": "Card.Menu", + "props": [] + }, + { + "name": "Card.Metadata", + "props": [] + } + ] +} diff --git a/packages/react/src/Card/Card.features.stories.tsx b/packages/react/src/Card/Card.features.stories.tsx new file mode 100644 index 00000000000..67e664489c8 --- /dev/null +++ b/packages/react/src/Card/Card.features.stories.tsx @@ -0,0 +1,56 @@ +import type {Meta} from '@storybook/react-vite' +import {RepoIcon, StarIcon} from '@primer/octicons-react' +import {Card} from './index' + +const meta = { + title: 'Experimental/Components/Card/Features', + component: Card, +} satisfies Meta + +export default meta + +export const WithImage = () => { + return ( +
+ + + 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 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.module.css b/packages/react/src/Card/Card.module.css new file mode 100644 index 00000000000..7adf2cdffcd --- /dev/null +++ b/packages/react/src/Card/Card.module.css @@ -0,0 +1,101 @@ +.Card { + display: grid; + position: relative; + border-radius: var(--borderRadius-large); + overflow: hidden; + grid-auto-rows: max-content auto; + border: var(--borderWidth-thin) solid var(--borderColor-default); + box-shadow: var(--shadow-resting-small); + background-color: var(--bgColor-default); + gap: var(--stack-gap-normal); + + &[data-padding='normal'] { + /* stylelint-disable-next-line primer/spacing */ + padding: var(--stack-padding-spacious); + } + + &[data-padding='condensed'] { + /* stylelint-disable-next-line primer/spacing */ + padding: var(--stack-padding-condensed); + } + + &[data-padding='none'] { + padding: 0; + } +} + +.CardHeader { + display: block; + width: 100%; + height: auto; +} + +.CardHeaderEdgeToEdge { + /* stylelint-disable primer/spacing */ + margin-top: calc(-1 * var(--stack-padding-spacious)); + margin-right: calc(-1 * var(--stack-padding-spacious)); + margin-left: calc(-1 * var(--stack-padding-spacious)); + width: calc(100% + 2 * var(--stack-padding-spacious)); + /* stylelint-enable primer/spacing */ +} + +.CardImage { + display: block; + width: 100%; + height: auto; +} + +.CardIcon { + display: flex; + align-items: center; + justify-content: center; + width: var(--base-size-32); + height: var(--base-size-32); + border-radius: var(--borderRadius-medium); + background-color: var(--bgColor-muted); + color: var(--fgColor-muted); +} + +.CardBody { + display: grid; + gap: var(--stack-gap-normal); +} + +.CardContent { + display: grid; + gap: var(--stack-gap-condensed); +} + +.CardHeading { + font: var(--text-title-shorthand-small); + color: var(--fgColor-default); + margin: 0; +} + +.CardDescription { + font: var(--text-body-shorthand-medium); + color: var(--fgColor-muted); + margin: 0; +} + +.CardMetadataContainer { + display: flex; + align-items: center; + gap: var(--stack-gap-normal); + font: var(--text-body-shorthand-medium); + color: var(--fgColor-muted); +} + +.CardMetadataItem { + display: flex; + align-items: center; + gap: var(--stack-gap-condensed); + font: var(--text-body-shorthand-small); +} + +.CardMenu { + position: absolute; + top: var(--base-size-16); + right: var(--base-size-16); + z-index: 1; +} diff --git a/packages/react/src/Card/Card.stories.tsx b/packages/react/src/Card/Card.stories.tsx new file mode 100644 index 00000000000..eabaefc2b68 --- /dev/null +++ b/packages/react/src/Card/Card.stories.tsx @@ -0,0 +1,62 @@ +import type {Meta, StoryFn} from '@storybook/react-vite' +import {RocketIcon} from '@primer/octicons-react' +import {Card} from './index' + +const meta = { + title: 'Experimental/Components/Card', + component: Card, +} 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 + +
+ ) +} + +type PlaygroundArgs = { + showIcon: boolean + showMetadata: boolean + padding: 'normal' | 'none' +} + +export const Playground: StoryFn = ({showIcon, showMetadata, padding}) => ( +
+ + {showIcon && } + Playground Card + Experiment with the Card component and its subcomponents. + {showMetadata && Just now} + +
+) + +Playground.args = { + showIcon: true, + showMetadata: true, + padding: 'normal', +} + +Playground.argTypes = { + showIcon: { + control: {type: 'boolean'}, + description: 'Show or hide the Card.Icon subcomponent', + }, + showMetadata: { + control: {type: 'boolean'}, + description: 'Show or hide the Card.Metadata subcomponent', + }, + padding: { + control: {type: 'radio'}, + options: ['none', 'condensed', 'normal'], + description: 'Controls the internal padding of the Card', + }, +} diff --git a/packages/react/src/Card/Card.test.tsx b/packages/react/src/Card/Card.test.tsx new file mode 100644 index 00000000000..902fb5f67af --- /dev/null +++ b/packages/react/src/Card/Card.test.tsx @@ -0,0 +1,156 @@ +import {describe, expect, it} from 'vitest' +import {render, screen} from '@testing-library/react' +import {Card} from '../Card' +import {implementsClassName} from '../utils/testing' +import classes from './Card.module.css' + +const TestIcon = () => + +describe('Card', () => { + implementsClassName(props => , classes.Card) + + it('should render a Card with heading and description', () => { + render( + + Test Heading + Test Description + , + ) + expect(screen.getByText('Test Heading')).toBeInTheDocument() + expect(screen.getByText('Test Description')).toBeInTheDocument() + }) + + it('should render a heading as an h3 element', () => { + render( + + Heading + , + ) + expect(screen.getByRole('heading', {level: 3, name: 'Heading'})).toBeInTheDocument() + }) + + it('should render an icon', () => { + render( + + + With Icon + , + ) + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + }) + + it('should render an image', () => { + render( + + + With Image + , + ) + const img = screen.getByRole('img', {name: 'Example'}) + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute('src', 'https://example.com/image.png') + }) + + it('should render metadata', () => { + render( + + Metadata Card + Updated 2 hours ago + , + ) + expect(screen.getByText('Updated 2 hours ago')).toBeInTheDocument() + }) + + it('should render a menu', () => { + render( + + Menu Card + + + + , + ) + expect(screen.getByRole('button', {name: 'Options'})).toBeInTheDocument() + }) + + it('should apply edge-to-edge styling when image is provided', () => { + const {container} = render( + + + Edge to Edge + , + ) + const header = container.querySelector(`.${classes.CardHeader}`) + expect(header).toHaveClass(classes.CardHeaderEdgeToEdge) + }) + + it('should not apply edge-to-edge styling when only icon is provided', () => { + const {container} = render( + + + With Icon + , + ) + const header = container.querySelector(`.${classes.CardHeader}`) + expect(header).not.toHaveClass(classes.CardHeaderEdgeToEdge) + }) + + it('should support a custom className on the root element', () => { + const {container} = render( + + Custom + , + ) + expect(container.firstChild).toHaveClass('custom-class') + expect(container.firstChild).toHaveClass(classes.Card) + }) + + it('should forward a ref to the root element', () => { + const ref = {current: null as HTMLDivElement | null} + render( + + Ref Card + , + ) + expect(ref.current).toBeInstanceOf(HTMLDivElement) + }) + + it('should render arbitrary custom content when no subcomponents are used', () => { + render( + +
+

Custom paragraph

+
+
, + ) + expect(screen.getByTestId('custom-content')).toBeInTheDocument() + expect(screen.getByText('Custom paragraph')).toBeInTheDocument() + }) + + it('should set data-padding to normal by default', () => { + const {container} = render( + + Padded + , + ) + expect(container.firstChild).toHaveAttribute('data-padding', 'normal') + }) + + it('should set data-padding to none when padding="none"', () => { + const {container} = render( + + No Padding + , + ) + expect(container.firstChild).toHaveAttribute('data-padding', 'none') + }) + + it('should set data-padding on custom content cards', () => { + const {container} = render( + +

Custom

+
, + ) + expect(container.firstChild).toHaveAttribute('data-padding', 'none') + }) +}) diff --git a/packages/react/src/Card/Card.tsx b/packages/react/src/Card/Card.tsx new file mode 100644 index 00000000000..82567d5b112 --- /dev/null +++ b/packages/react/src/Card/Card.tsx @@ -0,0 +1,188 @@ +import {clsx} from 'clsx' +import React, {forwardRef} from 'react' +import classes from './Card.module.css' + +export type CardProps = React.ComponentPropsWithoutRef<'div'> & { + /** + * Provide an optional className to add to the outermost element rendered by + * the Card + */ + className?: string + + /** + * Controls the internal padding of the Card. + * @default 'normal' + */ + padding?: 'none' | 'condensed' | 'normal' +} + +type HeadingLevel = 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + +type HeadingProps = React.ComponentPropsWithoutRef<'h3'> & { + /** + * The heading level to render. Defaults to 'h3'. + */ + as?: HeadingLevel + children: React.ReactNode +} + +type DescriptionProps = React.ComponentPropsWithoutRef<'p'> & { + children: React.ReactNode +} + +type IconProps = { + /** + * An Octicon or custom SVG icon to render + */ + icon: React.ElementType + /** + * Accessible label for the icon. When omitted, the icon is treated as decorative. + */ + 'aria-label'?: string + className?: string +} + +type ImageProps = React.ComponentPropsWithoutRef<'img'> & { + /** + * The image source URL + */ + src: string + /** + * Alt text for accessibility + */ + alt?: string +} + +type MenuProps = { + children: React.ReactNode +} + +type MetadataProps = React.ComponentPropsWithoutRef<'div'> & { + children: React.ReactNode +} + +const CardImpl = forwardRef(function Card( + {children, className, padding = 'normal', ...rest}, + ref, +) { + let icon: React.ReactNode = null + let image: React.ReactNode = null + let heading: React.ReactNode = null + let description: React.ReactNode = null + let metadata: React.ReactNode = null + let menu: React.ReactNode = null + + const childArray = React.Children.toArray(children) + + for (const child of childArray) { + if (!React.isValidElement(child)) continue + + if (child.type === CardIcon) { + icon = child + } else if (child.type === CardImage) { + image = child + } else if (child.type === CardHeading) { + heading = child + } else if (child.type === CardDescription) { + description = child + } else if (child.type === CardMetadata) { + metadata = child + } else if (child.type === CardMenu) { + menu = child + } + } + + const hasSlotChildren = icon || image || heading || description || metadata || menu + + if (!hasSlotChildren) { + return ( +
+ {children} +
+ ) + } + + return ( +
+ {(image || icon) && ( +
{image || icon}
+ )} +
+
+ {heading} + {description} +
+ {metadata ?
{metadata}
: null} +
+ {menu ?
{menu}
: null} +
+ ) +}) + +const CardIcon = ({icon: IconComponent, 'aria-label': ariaLabel, className}: IconProps) => { + return ( + + + + ) +} + +CardIcon.displayName = 'Card.Icon' + +const CardImage = ({src, alt = '', className, ...rest}: ImageProps) => { + return {alt} +} + +CardImage.displayName = 'Card.Image' + +const CardHeading = forwardRef(function CardHeading( + {as: Component = 'h3', children, className, ...rest}, + ref, +) { + return ( + + {children} + + ) +}) + +const CardDescription = forwardRef(function CardDescription( + {children, className, ...rest}, + ref, +) { + return ( +

+ {children} +

+ ) +}) + +const CardMenu = ({children}: MenuProps) => { + return <>{children} +} + +CardMenu.displayName = 'Card.Menu' + +const CardMetadata = forwardRef(function CardMetadata( + {children, className, ...rest}, + ref, +) { + return ( +
+ {children} +
+ ) +}) + +export {CardImpl, CardIcon, CardImage, CardHeading, CardDescription, CardMenu, CardMetadata} +export type {HeadingProps as CardHeadingProps} +export type {DescriptionProps as CardDescriptionProps} +export type {IconProps as CardIconProps} +export type {ImageProps as CardImageProps} +export type {MenuProps as CardMenuProps} +export type {MetadataProps as CardMetadataProps} diff --git a/packages/react/src/Card/index.ts b/packages/react/src/Card/index.ts new file mode 100644 index 00000000000..a00b1d69385 --- /dev/null +++ b/packages/react/src/Card/index.ts @@ -0,0 +1,30 @@ +import {CardImpl, CardIcon, CardImage, CardHeading, CardDescription, CardMenu, CardMetadata} from './Card' +import type { + CardProps, + CardIconProps, + CardImageProps, + CardHeadingProps, + CardDescriptionProps, + CardMenuProps, + CardMetadataProps, +} from './Card' + +const Card = Object.assign(CardImpl, { + Icon: CardIcon, + Image: CardImage, + Heading: CardHeading, + Description: CardDescription, + Menu: CardMenu, + Metadata: CardMetadata, +}) + +export {Card} +export type { + CardProps, + CardIconProps, + CardImageProps, + CardHeadingProps, + CardDescriptionProps, + CardMenuProps, + CardMetadataProps, +} diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index cec11bdd137..cb28e5a5d03 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -281,6 +281,14 @@ exports[`@primer/react/experimental > should not update exports without a semver "type BlankslateProps", "ButtonBase", "type ButtonBaseProps", + "Card", + "type CardDescriptionProps", + "type CardHeadingProps", + "type CardIconProps", + "type CardImageProps", + "type CardMenuProps", + "type CardMetadataProps", + "type CardProps", "type CellAlignment", "type Column", "type ColumnWidth", diff --git a/packages/react/src/experimental/index.ts b/packages/react/src/experimental/index.ts index 4c194a02f24..32adf323246 100644 --- a/packages/react/src/experimental/index.ts +++ b/packages/react/src/experimental/index.ts @@ -14,6 +14,17 @@ export type {BlankslateProps} from '../Blankslate' export {ButtonBase} from '../Button' export type {ButtonBaseProps} from '../Button' +export {Card} from '../Card' +export type { + CardProps, + CardIconProps, + CardImageProps, + CardHeadingProps, + CardDescriptionProps, + CardMenuProps, + CardMetadataProps, +} from '../Card' + export {DataTable, Table, createColumnHelper} from '../DataTable' export type { DataTableProps, diff --git a/script/generate-e2e-tests.js b/script/generate-e2e-tests.js index fc80d76ddce..e086b73d662 100755 --- a/script/generate-e2e-tests.js +++ b/script/generate-e2e-tests.js @@ -289,6 +289,25 @@ const components = new Map([ ], }, ], + [ + 'Card', + { + stories: [ + { + id: 'experimental-components-card--default', + name: 'Default', + }, + { + id: 'experimental-components-card-features--with-image', + name: 'With Image', + }, + { + id: 'experimental-components-card-features--with-metadata', + name: 'With Metadata', + }, + ], + }, + ], [ 'Checkbox', {