From 229ab6d1da4604a203cc50260497041bf63e07da Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Tue, 20 Oct 2026 15:35:59 -0700 Subject: [PATCH 1/3] feat(Card): incorporate early adoption feedback Add optional shadows and whole-card heading actions, standardize padding, and improve custom card layouts. Record the default padding change as breaking; migration planning remains follow-up work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/card-adoption-feedback.md | 5 + .changeset/card-flexible-layout.md | 5 + .changeset/card-image-padding.md | 5 + .changeset/card-padding-scale.md | 5 + e2e/components/Card.test.ts | 147 ++++++++++ packages/react/src/Card/Card.docs.json | 69 ++++- .../react/src/Card/Card.features.stories.tsx | 135 +++++++-- packages/react/src/Card/Card.module.css | 108 +++++++- .../react/src/Card/Card.stories.module.css | 41 ++- packages/react/src/Card/Card.stories.tsx | 23 +- packages/react/src/Card/Card.test.tsx | 256 +++++++++++++++++- packages/react/src/Card/Card.tsx | 61 ++++- packages/react/src/Card/index.ts | 18 +- packages/react/src/experimental/index.ts | 2 + 14 files changed, 824 insertions(+), 56 deletions(-) create mode 100644 .changeset/card-adoption-feedback.md create mode 100644 .changeset/card-flexible-layout.md create mode 100644 .changeset/card-image-padding.md create mode 100644 .changeset/card-padding-scale.md diff --git a/.changeset/card-adoption-feedback.md b/.changeset/card-adoption-feedback.md new file mode 100644 index 00000000000..ea889eb4cb9 --- /dev/null +++ b/.changeset/card-adoption-feedback.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Card: Add optional shadows and whole-card links and buttons with hover and keyboard focus indicators. diff --git a/.changeset/card-flexible-layout.md b/.changeset/card-flexible-layout.md new file mode 100644 index 00000000000..fa693bbe372 --- /dev/null +++ b/.changeset/card-flexible-layout.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Card: Improve shrinking and equal-height layouts, and keep secondary controls accessible in custom whole-card compositions. diff --git a/.changeset/card-image-padding.md b/.changeset/card-image-padding.md new file mode 100644 index 00000000000..f776cdaa991 --- /dev/null +++ b/.changeset/card-image-padding.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Card: Keep images edge-to-edge when using condensed or no padding. diff --git a/.changeset/card-padding-scale.md b/.changeset/card-padding-scale.md new file mode 100644 index 00000000000..6820dac7b0a --- /dev/null +++ b/.changeset/card-padding-scale.md @@ -0,0 +1,5 @@ +--- +'@primer/react': major +--- + +Card: Standardize padding across layouts: `normal` now uses 16px instead of 24px in the default layout, and the new `spacious` option provides 24px. diff --git a/e2e/components/Card.test.ts b/e2e/components/Card.test.ts index 1b583566aac..7738fb37a64 100644 --- a/e2e/components/Card.test.ts +++ b/e2e/components/Card.test.ts @@ -3,6 +3,153 @@ import {visit} from '../test-helpers/storybook' import {themes} from '../test-helpers/themes' test.describe('Card', () => { + test.describe('Whole-card interactions', () => { + test('custom composition keeps header and footer actions separate from whole-card navigation', async ({page}) => { + await visit(page, {id: 'experimental-components-card-features--custom-content'}) + const card = page.locator('[data-component="Card"]').first() + const pin = page.getByRole('button', {name: 'Pin primer/react', exact: true}) + await page.getByRole('link', {name: 'primer/react', exact: true}).focus() + await page.keyboard.press('Tab') + await expect(pin).toBeFocused() + const pressed = await pin.getAttribute('aria-pressed') + await pin.click() + await expect(pin).toHaveAttribute('aria-pressed', pressed === 'true' ? 'false' : 'true') + await expect(pin).toBeFocused() + await expect(page).not.toHaveURL(/#primer\/react$/) + const star = page.getByRole('button', {name: 'Star primer/react', exact: true}) + await page.keyboard.press('Tab') + await expect(star).toBeFocused() + await star.click() + await expect(star).toBeFocused() + await expect(page).not.toHaveURL(/#primer\/react$/) + await page.getByRole('button', {name: 'More options for primer/react', exact: true}).click() + await expect(page.getByRole('menuitem', {name: 'Repository settings'})).toBeVisible() + await page.keyboard.press('Escape') + await card.click({position: {x: 8, y: 8}}) + await expect(page).toHaveURL(/#primer\/react$/) + }) + + test('custom content fills equal-height cards and bottom-aligns footers', async ({page}) => { + await visit(page, {id: 'experimental-components-card-features--custom-content'}) + const cards = page.locator('[data-component="Card"]') + const bounds = await cards.evaluateAll(elements => + elements.map(element => { + const card = element.getBoundingClientRect() + const footer = + element.querySelector('[data-component="Card.Metadata"]') ?? element.firstElementChild!.lastElementChild! + return {height: card.height, width: card.width, bottom: footer.getBoundingClientRect().bottom} + }), + ) + expect(bounds[0].height).toBeCloseTo(bounds[1].height) + expect(bounds[0].bottom).toBeCloseTo(bounds[1].bottom) + expect(bounds[0].width).toBeLessThan(201) + expect(bounds[1].width).toBeLessThan(201) + }) + + test('primary hover border follows the hit area without animating or changing resting shadow', async ({page}) => { + await visit(page, {id: 'experimental-components-card-features--with-link'}) + const card = page.locator('[data-component="Card"]') + const link = page.getByRole('link', {name: 'primer/react', exact: true}) + await card.hover({position: {x: 8, y: 8}}) + const styles = await link.evaluate(element => ({ + hover: getComputedStyle(element, '::after').outlineStyle, + transform: getComputedStyle(element, '::after').transform, + transition: getComputedStyle(element, '::after').transitionDuration, + })) + expect(styles.hover).toBe('solid') + expect(styles.transform).toBe('none') + expect(styles.transition).toBe('0s') + await expect(card).toHaveCSS('box-shadow', 'none') + await page.getByRole('button', {name: 'Star primer/react'}).hover() + expect(await link.evaluate(element => getComputedStyle(element, '::after').outlineStyle)).toBe('none') + }) + + test('link hit area covers the card and secondary actions remain independent', async ({page}) => { + await visit(page, {id: 'experimental-components-card-features--with-link'}) + const card = page.locator('[data-component="Card"]') + const link = page.getByRole('link', {name: 'primer/react', exact: true}) + await page.getByRole('button', {name: 'Star primer/react'}).click() + await expect(page.getByRole('button', {name: 'Star primer/react'})).toBeFocused() + await expect(page).not.toHaveURL(/#primer-react$/) + await page.getByRole('button', {name: 'More options for primer/react'}).click() + await expect(page.getByRole('button', {name: 'More options for primer/react'})).toBeFocused() + await expect(page).not.toHaveURL(/#primer-react$/) + await card.click({position: {x: 8, y: 8}}) + await expect(page).toHaveURL(/#primer-react$/) + await expect(link).toBeFocused() + }) + + for (const layout of ['default', 'compact']) { + test(`button hit area and keyboard focus cover the ${layout} card`, async ({page}) => { + await visit(page, {id: 'experimental-components-card-features--with-button'}) + const card = page.locator('[data-component="Card"]').first() + await card.evaluate((element, value) => element.setAttribute('data-layout', value), layout) + const button = page.getByRole('button', {name: 'Open repository details'}) + await button.evaluate(element => { + element.setAttribute('data-activations', '0') + element.addEventListener('click', () => { + element.setAttribute('data-activations', String(Number(element.getAttribute('data-activations')) + 1)) + }) + }) + await card.click({position: {x: 8, y: 8}}) + await expect(button).toHaveAttribute('data-activations', '1') + await button.press('Enter') + await button.press('Space') + await expect(button).toHaveAttribute('data-activations', '3') + await expect(button).toBeFocused() + const outline = await button.evaluate(element => { + const style = getComputedStyle(element, '::after') + return {width: style.outlineWidth, style: style.outlineStyle} + }) + expect(outline).toEqual({width: '2px', style: 'solid'}) + const cardBounds = await card.boundingBox() + const overlay = await button.evaluate(element => { + const style = getComputedStyle(element, '::after') + return {width: Number.parseFloat(style.width), height: Number.parseFloat(style.height)} + }) + expect(overlay.width).toBeCloseTo(cardBounds!.width - 2) + expect(overlay.height).toBeCloseTo(cardBounds!.height - 2) + }) + } + + test('disabled button hit area does not activate', async ({page}) => { + await visit(page, {id: 'experimental-components-card-features--with-button'}) + const button = page.getByRole('button', {name: 'Repository details unavailable'}) + await button.evaluate(element => { + element.setAttribute('data-activated', 'false') + element.addEventListener('click', () => { + element.setAttribute('data-activated', 'true') + }) + }) + const card = page.locator('[data-component="Card"]').last() + const bounds = await card.boundingBox() + await page.mouse.click(bounds!.x + 8, bounds!.y + 8) + await expect(button).toBeDisabled() + await expect(button).toHaveAttribute('data-activated', 'false') + await card.hover({position: {x: 8, y: 8}}) + expect(await button.evaluate(element => getComputedStyle(element, '::after').outlineStyle)).toBe('none') + await page.keyboard.press('Tab') + await expect(button).not.toBeFocused() + }) + + test('button example starts closed and lets the user open and dismiss details', async ({page}) => { + await visit(page, {id: 'experimental-components-card-features--with-button'}) + const details = page.getByRole('region', {name: 'Repository details'}) + await expect(details).toHaveCount(0) + await page.getByRole('button', {name: 'Open repository details'}).click() + await expect(details).toBeVisible() + await page.getByRole('button', {name: 'Close repository details'}).click() + await expect(details).toHaveCount(0) + }) + + for (const story of ['with-link', 'with-button', 'custom-content']) { + test(`${story} accessibility @avt`, async ({page}) => { + await visit(page, {id: `experimental-components-card-features--${story}`}) + await expect(page).toHaveNoViolations() + }) + } + }) + test.describe('Default', () => { for (const theme of themes) { test.describe(theme, () => { diff --git a/packages/react/src/Card/Card.docs.json b/packages/react/src/Card/Card.docs.json index 3d8585c585b..4c1091fefe1 100644 --- a/packages/react/src/Card/Card.docs.json +++ b/packages/react/src/Card/Card.docs.json @@ -11,6 +11,18 @@ { "id": "experimental-components-card-features--with-image" }, + { + "id": "experimental-components-card-features--compact" + }, + { + "id": "experimental-components-card-features--with-link" + }, + { + "id": "experimental-components-card-features--with-button" + }, + { + "id": "experimental-components-card-features--custom-content" + }, { "id": "experimental-components-card-features--with-metadata" }, @@ -32,7 +44,7 @@ "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." + "description": "The contents of the card. Provide either direct `Card.*` subcomponents (for example `Card.Heading`, `Card.Description`, `Card.Metadata`) for the predefined layout or a custom wrapper for free-form composition. A custom wrapper fills the available card height, allowing a flex-column body with margin-top: auto on footer actions. Grid parents stretch cards by default; when cards are wrapped in list items, consumers must also make the cards fill those items. A card with no children will not render." }, { "name": "className", @@ -41,9 +53,21 @@ }, { "name": "padding", - "type": "'none' | 'condensed' | 'normal'", + "type": "'none' | 'condensed' | 'normal' | 'spacious'", "defaultValue": "'normal'", - "description": "Controls the internal padding of the Card." + "description": "Internal padding in either layout: none is 0px, condensed is 8px, normal is 16px, and spacious is 24px. Defaults to normal." + }, + { + "name": "shadow", + "type": "boolean", + "defaultValue": "true", + "description": "Whether to show the Card shadow. Set to false for a bordered, shadowless card in dense dashboards." + }, + { + "name": "layout", + "type": "'default' | 'compact'", + "defaultValue": "'default'", + "description": "Compact layout places the icon beside the content, removes its background container, and uses a smaller heading and tighter spacing." }, { "name": "borderRadius", @@ -101,6 +125,45 @@ } ] }, + { + "name": "Card.Link", + "props": [ + { + "name": "href", + "type": "string", + "required": true, + "description": "Navigation destination. Place Card.Link inside Card.Heading to extend the native link's hit area over the card. Card supplies the hit area, hover border, and visible keyboard focus ring; the native link supplies keyboard activation, modifier-click, and browser context menus. Consumers provide a descriptive link name and destination. Use only one primary Card.Link or Card.Button per card and do not nest interactive controls inside it. Secondary native controls can be placed in Card.Action, Card.Metadata, or custom headers and footers; Card layers them above the primary hit area. Keep wrappers between the primary link and Card unpositioned so its hit area remains card-wide. Other card text cannot be selected with a pointer under the expanded hit area; use a regular Link in Card.Heading instead when text selection or richer interactive content is needed." + }, + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "Descriptive link text, used as the accessible name rather than the entire card contents. Accepts native anchor attributes and forwards its ref to the anchor." + } + ] + }, + { + "name": "Card.Button", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "Descriptive action text. Place Card.Button inside Card.Heading for a whole-card action that does not navigate. Card supplies the expanded hit area, hover border, and visible focus ring; the native button supplies Enter/Space activation and disabled behavior. Consumers provide the accessible name and onClick handler. Use only one primary Card.Link or Card.Button per card. Put secondary controls outside the primary button, in Card.Action, Card.Metadata, or custom headers and footers. Keep wrappers between the primary button and Card unpositioned so its hit area remains card-wide. Accepts native button attributes and forwards its ref to the button." + }, + { + "name": "type", + "type": "'button' | 'submit' | 'reset'", + "defaultValue": "'button'", + "description": "Native button type. Defaults to button to avoid accidentally submitting a surrounding form." + }, + { + "name": "disabled", + "type": "boolean", + "description": "Disables the primary action, including its expanded hit area, and removes it from the tab order. Secondary controls remain independent." + } + ] + }, { "name": "Card.Description", "props": [ diff --git a/packages/react/src/Card/Card.features.stories.tsx b/packages/react/src/Card/Card.features.stories.tsx index 5f716946787..49522d81a52 100644 --- a/packages/react/src/Card/Card.features.stories.tsx +++ b/packages/react/src/Card/Card.features.stories.tsx @@ -1,5 +1,7 @@ -import type {Meta} from '@storybook/react-vite' -import {KebabHorizontalIcon, RepoIcon, RepoForkedIcon, StarIcon} from '@primer/octicons-react' +import {useState} from 'react' +import type {Meta, StoryFn} from '@storybook/react-vite' +import {expect, userEvent, within} from 'storybook/test' +import {KebabHorizontalIcon, PinIcon, RepoIcon, RepoForkedIcon, StarIcon} from '@primer/octicons-react' import {ActionList} from '../ActionList' import {ActionMenu} from '../ActionMenu' import {Button, IconButton} from '../Button' @@ -21,6 +23,121 @@ const meta = { export default meta +export const WithLink = () => ( + + + + primer/react + + The entire card links to the repository. The Star action remains separate. + + + + + + + +) + +export const WithButton: StoryFn = () => { + const [opened, setOpened] = useState(false) + return ( +
+ + + + setOpened(true)}>Open repository details + + Use a button for an action that does not navigate to another page. + + + + Repository details unavailable + + A disabled primary action cannot be activated. + + {opened && ( +
+

primer/react

+

{"React components for GitHub's Primer Design System."}

+ +
+ )} +
+ ) +} + +WithButton.play = async ({canvasElement}) => { + const canvas = within(canvasElement) + await userEvent.click(canvas.getByRole('button', {name: 'Open repository details'})) + await expect(canvas.getByRole('region', {name: 'Repository details'})).toBeVisible() + await userEvent.click(canvas.getByRole('button', {name: 'Close repository details'})) + await expect(canvas.queryByRole('region', {name: 'Repository details'})).not.toBeInTheDocument() +} + +export const CustomContent: StoryFn = () => { + const [pinned, setPinned] = useState([]) + return ( +
+ {['primer/react', 'primer/primitives-with-a-long-unbroken-repository-name'].map((name, index) => ( + +
+
+ + {name} + + + setPinned(value => (value.includes(name) ? value.filter(item => item !== name) : [...value, name])) + } + /> +
+ + {index === 0 + ? 'React components.' + : 'Design tokens for color, spacing, typography, and motion, shared across GitHub interfaces.'} + +
+ + + + + + + + Repository settings + + + +
+
+
+ ))} +
+ ) +} + +CustomContent.play = async ({canvasElement}) => { + const canvas = within(canvasElement) + const pin = canvas.getByRole('button', {name: 'Pin primer/react'}) + await userEvent.click(pin) + await expect(pin).toHaveAttribute('aria-pressed', 'true') +} + export const WithImage = () => { return ( @@ -89,20 +206,6 @@ export const WithMenu = () => { ) } -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 = () => ( diff --git a/packages/react/src/Card/Card.module.css b/packages/react/src/Card/Card.module.css index e0e463e81d9..650915b95f6 100644 --- a/packages/react/src/Card/Card.module.css +++ b/packages/react/src/Card/Card.module.css @@ -1,12 +1,30 @@ .Card { + --card-padding: var(--stack-padding-normal); + display: grid; position: relative; + isolation: isolate; overflow: hidden; + min-width: 0; 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); + /* stylelint-disable-next-line primer/spacing */ + padding: var(--card-padding); + + &[data-content='custom'] { + grid-auto-rows: auto; + } + + &[data-content='structured'][data-has-header='false'] { + grid-template-rows: minmax(0, 1fr); + } + + &[data-shadow='false'] { + box-shadow: none; + } &[data-border-radius='large'] { border-radius: var(--borderRadius-large); @@ -16,18 +34,16 @@ border-radius: var(--borderRadius-medium); } - &[data-padding='normal'] { - /* stylelint-disable-next-line primer/spacing */ - padding: var(--stack-padding-spacious); + &[data-padding='spacious'] { + --card-padding: var(--stack-padding-spacious); } &[data-padding='condensed'] { - /* stylelint-disable-next-line primer/spacing */ - padding: var(--stack-padding-condensed); + --card-padding: var(--stack-padding-condensed); } &[data-padding='none'] { - padding: 0; + --card-padding: 0px; } &[data-layout='compact'] { @@ -35,10 +51,20 @@ align-items: flex-start; gap: var(--stack-gap-condensed); } +} - &[data-layout='compact'][data-padding='normal'] { - /* stylelint-disable-next-line primer/spacing */ - padding: var(--stack-padding-normal); +:where(.Card) { + :where(a), + :where(button), + :where(input), + :where(select), + :where(textarea), + :where(summary), + :where(label), + :where([tabindex]), + :where([contenteditable='true']) { + position: relative; + z-index: 1; } } @@ -55,10 +81,10 @@ .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)); + margin-top: calc(-1 * var(--card-padding)); + margin-right: calc(-1 * var(--card-padding)); + margin-left: calc(-1 * var(--card-padding)); + width: calc(100% + 2 * var(--card-padding)); /* stylelint-enable primer/spacing */ } @@ -85,6 +111,7 @@ .CardBody { display: grid; + min-width: 0; gap: var(--stack-gap-normal); .Card:where([data-layout='compact']) & { @@ -94,6 +121,8 @@ .CardContent { display: grid; + min-width: 0; + align-content: start; gap: var(--stack-gap-condensed); } @@ -103,12 +132,60 @@ margin: 0; .Card:where([data-layout='compact']) & { - position: relative; - top: calc(-1 * var(--base-size-4)); + margin-top: calc(-1 * var(--base-size-4)); + margin-bottom: var(--base-size-4); font-size: var(--text-body-size-medium); } } +.CardPrimaryAction { + position: static; + z-index: auto; + padding: 0; + border: 0; + background: none; + font: inherit; + color: inherit; + text-align: inherit; + text-decoration: none; + cursor: pointer; + + &::after { + position: absolute; + inset: 0; + z-index: 0; + content: ''; + border-radius: var(--borderRadius-large); + + .Card:where([data-border-radius='medium']) & { + border-radius: var(--borderRadius-medium); + } + } + + &:hover:not(:disabled) { + text-decoration: underline; + } + + &:hover:where(:not(:disabled))::after { + outline: var(--borderWidth-thin) solid var(--borderColor-accent-emphasis); + outline-offset: calc(-1 * var(--borderWidth-thin)); + } + + &:focus-visible { + outline: none; + } + + &:focus-visible::after { + outline: var(--borderWidth-thick) solid var(--focus-outlineColor); + outline-offset: calc(-1 * var(--base-size-4)); + } + + &:disabled { + color: var(--fgColor-disabled); + cursor: default; + } +} + .CardDescription { font: var(--text-body-shorthand-medium); color: var(--fgColor-muted); @@ -117,6 +194,7 @@ .CardMetadataContainer { display: flex; + align-self: end; align-items: center; gap: var(--stack-gap-normal); font: var(--text-body-shorthand-medium); diff --git a/packages/react/src/Card/Card.stories.module.css b/packages/react/src/Card/Card.stories.module.css index bd3ff8acdeb..674aff06537 100644 --- a/packages/react/src/Card/Card.stories.module.css +++ b/packages/react/src/Card/Card.stories.module.css @@ -2,27 +2,44 @@ max-width: 400px; } -.CustomContentLayout { +.CardList { display: flex; flex-direction: column; gap: var(--stack-gap-condensed); -} - -.CustomContentLayout > h3, -.CustomContentLayout > p, -.CustomContentLayout > ul { margin: 0; + padding: 0; + list-style: none; } -.CustomContentLayout > ul { - padding-inline-start: var(--base-size-16); +.EqualHeightGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--stack-gap-normal); } -.CardList { +.ComposedBody { display: flex; flex-direction: column; + min-width: 0; + gap: var(--stack-gap-normal); +} + +.ComposedHeader { + display: flex; + align-items: flex-start; gap: var(--stack-gap-condensed); - margin: 0; - padding: 0; - list-style: none; +} + +.ComposedHeading { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.ComposedFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--stack-gap-condensed); + margin-top: auto; } diff --git a/packages/react/src/Card/Card.stories.tsx b/packages/react/src/Card/Card.stories.tsx index eef867be2a4..8a50496a762 100644 --- a/packages/react/src/Card/Card.stories.tsx +++ b/packages/react/src/Card/Card.stories.tsx @@ -34,12 +34,20 @@ type PlaygroundArgs = { showIcon: boolean showMetadata: boolean layout: 'default' | 'compact' - padding: 'none' | 'condensed' | 'normal' + padding: 'none' | 'condensed' | 'normal' | 'spacious' + shadow: boolean borderRadius: 'medium' | 'large' } -export const Playground: StoryFn = ({showIcon, showMetadata, layout, padding, borderRadius}) => ( - +export const Playground: StoryFn = ({ + showIcon, + showMetadata, + layout, + padding, + borderRadius, + shadow, +}) => ( + {showIcon && } Playground Card Experiment with the Card component and its subcomponents. @@ -53,6 +61,7 @@ Playground.args = { layout: 'default', padding: 'normal', borderRadius: 'large', + shadow: true, } Playground.argTypes = { @@ -71,12 +80,16 @@ Playground.argTypes = { }, padding: { control: {type: 'radio'}, - options: ['none', 'condensed', 'normal'], - description: 'Controls the internal padding of the Card', + options: ['none', 'condensed', 'normal', 'spacious'], + description: 'Internal padding: none (0px), condensed (8px), normal (16px), spacious (24px)', }, borderRadius: { control: {type: 'radio'}, options: ['medium', 'large'], description: 'Controls the border radius of the Card', }, + shadow: { + control: {type: 'boolean'}, + description: 'Show or hide the Card shadow', + }, } diff --git a/packages/react/src/Card/Card.test.tsx b/packages/react/src/Card/Card.test.tsx index aeab542fe61..2eb126ef973 100644 --- a/packages/react/src/Card/Card.test.tsx +++ b/packages/react/src/Card/Card.test.tsx @@ -1,5 +1,6 @@ -import {describe, expect, it} from 'vitest' +import {describe, expect, it, vi} from 'vitest' import {render, screen} from '@testing-library/react' +import userEvent from '@testing-library/user-event' import {Card} from '../Card' import {implementsClassName} from '../utils/testing' import classes from './Card.module.css' @@ -7,6 +8,128 @@ import classes from './Card.module.css' const TestIcon = () => describe('Card', () => { + it('lets a custom body fill the card height without overriding internal grid rows', () => { + render( + +
+

Repository

+
+ Updated today +
+
+
, + ) + const card = screen.getByTestId('card') + const body = screen.getByTestId('body') + const footer = screen.getByTestId('footer') + const style = getComputedStyle(card) + const bottomInset = Number.parseFloat(style.paddingBottom) + Number.parseFloat(style.borderBottomWidth) + expect(body.getBoundingClientRect().bottom).toBeCloseTo(card.getBoundingClientRect().bottom - bottomInset) + expect(footer.getBoundingClientRect().bottom).toBeCloseTo(body.getBoundingClientRect().bottom) + }) + + it.each([true, false])('bottom-aligns metadata in a stretched card with header=%s', header => { + render( + + {header && } + Repository + Short description + Updated today + , + ) + const card = screen.getByTestId('card') + const footer = screen.getByTestId('footer') + expect(footer.getBoundingClientRect().bottom).toBeCloseTo(card.getBoundingClientRect().bottom - 17) + }) + + it.each(['default', 'compact'] as const)('shrinks long content within a narrow flex parent in %s layout', layout => { + render( +
+ + + {'repository'.repeat(20)} + Long repository name + +
, + ) + expect(screen.getByTestId('card').getBoundingClientRect().width).toBeLessThanOrEqual(160) + }) + + it.each(['slots', 'custom'] as const)( + 'preserves the default shadow and supports opting out with %s content', + content => { + const children = content === 'slots' ? Heading :

Custom content

+ const {container, rerender} = render({children}) + const card = container.querySelector('[data-component="Card"]')! + expect(card).toHaveAttribute('data-shadow', 'true') + expect(getComputedStyle(card).boxShadow).not.toBe('none') + + rerender({children}) + expect(card).toHaveAttribute('data-shadow', 'false') + expect(getComputedStyle(card).boxShadow).toBe('none') + expect(card).not.toHaveAttribute('shadow') + + rerender({children}) + expect(getComputedStyle(card).boxShadow).not.toBe('none') + }, + ) + + describe.each(['default', 'compact'] as const)('%s layout padding', layout => { + it.each(['slots', 'custom'] as const)('defaults to 16px padding with %s content', content => { + const {container} = render( + + {content === 'slots' ? Heading :

Custom content

} +
, + ) + const card = container.querySelector('[data-component="Card"]')! + expect(card).toHaveAttribute('data-padding', 'normal') + expect(getComputedStyle(card).padding).toBe('16px') + }) + + it.each([ + ['none', 0], + ['condensed', 8], + ['normal', 16], + ['spacious', 24], + ] as const)('uses %s padding of %ipx for both content modes', (padding, pixels) => { + const {container, rerender} = render( + + Heading + , + ) + const card = container.querySelector('[data-component="Card"]')! + for (const side of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'] as const) { + expect(getComputedStyle(card)[side]).toBe(`${pixels}px`) + } + rerender( + +

Custom content

+
, + ) + expect(getComputedStyle(card).padding).toBe(`${pixels}px`) + }) + }) + + it.each(['none', 'condensed', 'normal', 'spacious'] as const)( + 'keeps images edge-to-edge with %s padding', + padding => { + const {container} = render( + + + Image card + , + ) + const card = container.querySelector('[data-component="Card"]')! + const image = screen.getByRole('presentation') + const cardBounds = card.getBoundingClientRect() + const imageBounds = image.getBoundingClientRect() + const borderWidth = Number.parseFloat(getComputedStyle(card).borderLeftWidth) + expect(imageBounds.left).toBeCloseTo(cardBounds.left + borderWidth) + expect(imageBounds.top).toBeCloseTo(cardBounds.top + borderWidth) + expect(imageBounds.width).toBeCloseTo(cardBounds.width - 2 * borderWidth) + }, + ) + implementsClassName( props => ( @@ -27,6 +150,137 @@ describe('Card', () => { expect(screen.getByText('Test Description')).toBeInTheDocument() }) + describe('Card.Link', () => { + implementsClassName( + props => ( + + Repository + + ), + classes.CardPrimaryAction, + ) + + it('renders a native heading link with a concise accessible name and forwards native attributes and ref', () => { + const ref = {current: null as HTMLAnchorElement | null} + render( + + + + primer/react + + + Repository details + , + ) + const link = screen.getByRole('link', {name: 'primer/react'}) + expect(link).toHaveAttribute('href', '/primer/react') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noreferrer') + expect(link).toHaveAttribute('data-component', 'Card.Link') + expect(ref.current).toBe(link) + expect(screen.getByRole('region', {name: 'primer/react'})).toBeInTheDocument() + expect(screen.getByRole('heading', {name: 'primer/react'})).toContainElement(link) + }) + + it('activates with Enter and keeps secondary actions independent', async () => { + const user = userEvent.setup() + const primary = vi.fn((event: React.MouseEvent) => event.preventDefault()) + const secondary = vi.fn() + render( + + + + Repository + + + + + + , + ) + await user.tab() + expect(screen.getByRole('link')).toHaveFocus() + await user.keyboard('{Enter}') + expect(primary).toHaveBeenCalledTimes(1) + await user.tab() + await user.keyboard('{Enter}') + expect(secondary).toHaveBeenCalledTimes(1) + expect(primary).toHaveBeenCalledTimes(1) + }) + }) + + describe('Card.Button', () => { + implementsClassName(props => Open details, classes.CardPrimaryAction) + + it('forwards its ref and native props and does not submit a form by default', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()) + const ref = {current: null as HTMLButtonElement | null} + render( +
+ + + + Open details + + + +
, + ) + const button = screen.getByRole('button', {name: 'Open details'}) + expect(button).toHaveAttribute('type', 'button') + expect(button).toHaveAttribute('aria-expanded', 'false') + expect(button).toHaveAttribute('data-component', 'Card.Button') + expect(ref.current).toBe(button) + await user.tab() + expect(button).toHaveFocus() + await user.keyboard('{Enter}') + await user.keyboard(' ') + expect(onClick).toHaveBeenCalledTimes(2) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('supports an explicit submit type', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault()) + render( +
+ + + Submit + + +
, + ) + await user.click(screen.getByRole('button', {name: 'Submit'})) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it('does not activate when disabled and leaves secondary controls enabled', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render( + + + + Open details + + + + + + , + ) + await user.click(screen.getByRole('button', {name: 'Open details'})) + expect(onClick).not.toHaveBeenCalled() + await user.tab() + expect(screen.getByRole('button', {name: 'Secondary action'})).toHaveFocus() + }) + }) + it('should render a heading as an h3 element', () => { render( diff --git a/packages/react/src/Card/Card.tsx b/packages/react/src/Card/Card.tsx index 46f79f41727..9b05f0675f5 100644 --- a/packages/react/src/Card/Card.tsx +++ b/packages/react/src/Card/Card.tsx @@ -16,8 +16,11 @@ export type CardProps = PolymorphicProps< /** Optional className for the root element. */ className?: string - /** Internal padding. @default 'normal' */ - padding?: 'none' | 'condensed' | 'normal' + /** Internal padding: none (0px), condensed (8px), normal (16px), spacious (24px). @default 'normal' */ + padding?: 'none' | 'condensed' | 'normal' | 'spacious' + + /** Whether to show the card's shadow. @default true */ + shadow?: boolean /** Border radius. @default 'large' */ borderRadius?: 'medium' | 'large' @@ -39,6 +42,16 @@ export type CardProps = PolymorphicProps< type HeadingLevel = 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +type LinkProps = React.ComponentPropsWithoutRef<'a'> & { + /** Destination for the whole-card link. */ + href: string + children: React.ReactNode +} + +type ButtonProps = React.ComponentPropsWithoutRef<'button'> & { + children: React.ReactNode +} + type HeadingProps = React.ComponentPropsWithoutRef<'h3'> & { /** * The heading level to render. Defaults to 'h3'. @@ -99,6 +112,7 @@ function CardComponent( children, className, padding = 'normal', + shadow = true, borderRadius = 'large', layout = 'default', as = 'div', @@ -155,7 +169,9 @@ function CardComponent( ref={ref} className={clsx(classes.Card, className)} data-component="Card" + data-content="custom" data-padding={padding} + data-shadow={shadow} data-border-radius={borderRadius} data-layout={layout} {...rest} @@ -172,7 +188,10 @@ function CardComponent( ref={ref} className={clsx(classes.Card, className)} data-component="Card" + data-content="structured" + data-has-header={Boolean(image || icon)} data-padding={padding} + data-shadow={shadow} data-border-radius={borderRadius} data-layout={layout} {...rest} @@ -247,6 +266,41 @@ const CardHeading = forwardRef(function CardHe CardHeading.displayName = 'Card.Heading' +/** + * Place inside Card.Heading to make the card a link. Keep secondary controls + * outside the link, in Card.Action or Card.Metadata. + */ +const CardLink = forwardRef(function CardLink({children, className, ...rest}, ref) { + return ( + + {children} + + ) +}) + +CardLink.displayName = 'Card.Link' + +/** + * Place inside Card.Heading to make the card trigger an action instead of + * navigating. Native button semantics provide Enter, Space, and disabled support. + */ +const CardButton = forwardRef(function CardButton( + {className, type = 'button', ...rest}, + ref, +) { + return ( +