Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/card-adoption-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Card: Add optional shadows and whole-card links and buttons with hover and keyboard focus indicators.
5 changes: 5 additions & 0 deletions .changeset/card-flexible-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Card: Improve shrinking and equal-height layouts, and keep secondary controls accessible in custom whole-card compositions.
5 changes: 5 additions & 0 deletions .changeset/card-image-padding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Card: Keep images edge-to-edge when using condensed or no padding.
5 changes: 5 additions & 0 deletions .changeset/card-regular-padding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Card: Add regular padding for 16px spacing in either layout, preserving existing default padding.
147 changes: 147 additions & 0 deletions e2e/components/Card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, () => {
Expand Down
69 changes: 66 additions & 3 deletions packages/react/src/Card/Card.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
Loading
Loading