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-regular-padding.md b/.changeset/card-regular-padding.md new file mode 100644 index 00000000000..a54080d7b31 --- /dev/null +++ b/.changeset/card-regular-padding.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Card: Add regular padding for 16px spacing in either layout, preserving existing default padding. 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..e78edce30ca 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' | 'regular' | 'normal'", "defaultValue": "'normal'", - "description": "Controls the internal padding of the Card." + "description": "Internal padding: none is 0px, condensed is 8px, and regular is 16px in either layout. The default normal padding remains 24px in the default layout and 16px in compact layout." + }, + { + "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..b05d102686a 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..0d1aac51903 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-spacious); + 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='regular'] { + --card-padding: var(--stack-padding-normal); } &[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'] { @@ -37,8 +53,22 @@ } &[data-layout='compact'][data-padding='normal'] { - /* stylelint-disable-next-line primer/spacing */ - padding: var(--stack-padding-normal); + --card-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 +85,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 +115,7 @@ .CardBody { display: grid; + min-width: 0; gap: var(--stack-gap-normal); .Card:where([data-layout='compact']) & { @@ -94,6 +125,8 @@ .CardContent { display: grid; + min-width: 0; + align-content: start; gap: var(--stack-gap-condensed); } @@ -103,12 +136,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 +198,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..fa2cf96f017 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' | 'regular' | 'normal' + 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,17 @@ Playground.argTypes = { }, padding: { control: {type: 'radio'}, - options: ['none', 'condensed', 'normal'], - description: 'Controls the internal padding of the Card', + options: ['none', 'condensed', 'regular', 'normal'], + description: + 'Internal padding: none (0px), condensed (8px), regular (16px), normal (24px, or 16px in compact layout)', }, 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..1ab6f0cdbe3 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,127 @@ 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') + const style = getComputedStyle(card) + const bottomInset = Number.parseFloat(style.paddingBottom) + Number.parseFloat(style.borderBottomWidth) + expect(footer.getBoundingClientRect().bottom).toBeCloseTo(card.getBoundingClientRect().bottom - bottomInset) + }) + + 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)('preserves default 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(layout === 'compact' ? '16px' : '24px') + }) + + it.each([ + ['none', 0], + ['condensed', 8], + ['regular', 16], + ['normal', layout === 'compact' ? 16 : 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', 'regular', 'normal'] 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 +149,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..8a9c263660f 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), regular (16px), normal (24px, or 16px in compact layout). @default 'normal' */ + padding?: 'none' | 'condensed' | 'regular' | 'normal' + + /** 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 ( +