From db927510dcca268752a8c76b107c49adcfa02795 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 4 Mar 2026 15:24:23 +0000 Subject: [PATCH 1/4] Add new Interactive card Signed-off-by: Charles de Dreuille --- .changeset/brown-rings-sort.md | 5 ++ .../src/app/components/card/components.tsx | 66 ++++++++++++++ docs-ui/src/app/components/card/page.mdx | 55 +++++++++++- .../app/components/card/props-definition.ts | 19 ++++ docs-ui/src/app/components/card/snippets.ts | 44 ++++++++++ packages/ui/report.api.md | 46 ++++++++-- .../ui/src/components/Card/Card.module.css | 42 +++++++++ .../ui/src/components/Card/Card.stories.tsx | 88 +++++++++++++++++++ packages/ui/src/components/Card/Card.tsx | 19 +++- packages/ui/src/components/Card/definition.ts | 4 + packages/ui/src/components/Card/types.ts | 42 ++++++++- 11 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 .changeset/brown-rings-sort.md diff --git a/.changeset/brown-rings-sort.md b/.changeset/brown-rings-sort.md new file mode 100644 index 0000000000..045f2fbd3b --- /dev/null +++ b/.changeset/brown-rings-sort.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Added interactive support to the `Card` component. Pass `onPress` to make the entire card surface pressable, or `href` to make it navigate to a URL. A transparent overlay handles the interaction while nested buttons and links remain independently clickable. diff --git a/docs-ui/src/app/components/card/components.tsx b/docs-ui/src/app/components/card/components.tsx index d10e4d7bce..c4d1f809c2 100644 --- a/docs-ui/src/app/components/card/components.tsx +++ b/docs-ui/src/app/components/card/components.tsx @@ -7,6 +7,8 @@ import { CardFooter, } from '../../../../../packages/ui/src/components/Card/Card'; import { Text } from '../../../../../packages/ui/src/components/Text/Text'; +import { Button } from '../../../../../packages/ui/src/components/Button/Button'; +import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex'; export const Default = () => { return ( @@ -32,6 +34,70 @@ export const HeaderAndBody = () => { ); }; +export const InteractiveButton = () => { + return ( + {}} + label="View component details" + > + Interactive Card + + Click anywhere on this card to trigger the press handler. + + + + Click to interact + + + + ); +}; + +export const InteractiveLink = () => { + return ( + + Link Card + This card navigates to a URL when clicked. + + + Opens backstage.io + + + + ); +}; + +export const InteractiveWithNestedButtons = () => { + return ( + {}} + label="View plugin details" + > + Card with Actions + + Clicking the card background triggers the card press handler. The + buttons below remain independently interactive. + + + + + + + + + ); +}; + export const WithLongBody = () => { return ( diff --git a/docs-ui/src/app/components/card/page.mdx b/docs-ui/src/app/components/card/page.mdx index 6f410fcd97..df4d531604 100644 --- a/docs-ui/src/app/components/card/page.mdx +++ b/docs-ui/src/app/components/card/page.mdx @@ -12,8 +12,18 @@ import { defaultSnippet, headerAndBodySnippet, withLongBodySnippet, + interactiveButtonSnippet, + interactiveLinkSnippet, + interactiveWithNestedButtonsSnippet, } from './snippets'; -import { Default, HeaderAndBody, WithLongBody } from './components'; +import { + Default, + HeaderAndBody, + WithLongBody, + InteractiveButton, + InteractiveLink, + InteractiveWithNestedButtons, +} from './components'; import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; import { CardDefinition } from '../../../utils/definitions'; @@ -81,6 +91,49 @@ When body content exceeds the available height, CardBody scrolls while header an code={withLongBodySnippet} /> +## Interactive cards + +Cards can be made interactive without wrapping the entire card in a button or link — which would conflict with any interactive elements inside. Instead, a transparent overlay covers the card surface, and nested buttons and links remain independently clickable above it. + +### Button + +Pass `onPress` and a `label` (used as the accessible name for screen readers) to make the whole card surface pressable. + +} + code={interactiveButtonSnippet} +/> + +### Link + +Pass `href` to make the card surface navigate to a URL. The `label` prop is optional but recommended for accessibility when the card content alone may not sufficiently describe the destination. + +} + code={interactiveLinkSnippet} +/> + +### With nested buttons + +Buttons and links inside the card remain independently interactive. Clicking them does not trigger the card's `onPress` handler. + +} + code={interactiveWithNestedButtonsSnippet} +/> + diff --git a/docs-ui/src/app/components/card/props-definition.ts b/docs-ui/src/app/components/card/props-definition.ts index b3e3776648..a0084c7158 100644 --- a/docs-ui/src/app/components/card/props-definition.ts +++ b/docs-ui/src/app/components/card/props-definition.ts @@ -15,6 +15,25 @@ const optionalChildrenPropDef: Record = { export const cardPropDefs: Record = { ...optionalChildrenPropDef, + onPress: { + type: 'enum', + values: ['() => void'], + responsive: false, + description: + 'Handler called when the card is pressed. Makes the card interactive as a button. Requires label.', + }, + href: { + type: 'string', + responsive: false, + description: + 'URL to navigate to. Makes the card interactive as a link. Mutually exclusive with onPress.', + }, + label: { + type: 'string', + responsive: false, + description: + 'Accessible label announced by screen readers for the interactive overlay. Required when onPress is provided.', + }, ...classNamePropDefs, ...stylePropDefs, }; diff --git a/docs-ui/src/app/components/card/snippets.ts b/docs-ui/src/app/components/card/snippets.ts index 6f956e5518..86b9df5e0a 100644 --- a/docs-ui/src/app/components/card/snippets.ts +++ b/docs-ui/src/app/components/card/snippets.ts @@ -17,6 +17,50 @@ export const headerAndBodySnippet = `Body content without a footer `; +export const interactiveButtonSnippet = ` console.log('Card pressed')} + label="View component details" +> + Interactive Card + Click anywhere on this card to trigger the press handler. + Click to interact +`; + +export const interactiveLinkSnippet = ` + Link Card + This card navigates to a URL when clicked. + Opens backstage.io +`; + +export const interactiveWithNestedButtonsSnippet = `import { Button, Flex } from '@backstage/ui'; + + console.log('Card pressed')} + label="View plugin details" +> + Card with Actions + + Clicking the card background triggers the card press handler. + The buttons below remain independently interactive. + + + + + + + +`; + export const withLongBodySnippet = `import { Text } from '@backstage/ui'; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index f1d3505ccf..503ec19414 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -565,6 +565,12 @@ export const Card: ForwardRefExoticComponent< CardProps & RefAttributes >; +// @public (undocumented) +export type CardBaseProps = { + children?: ReactNode; + className?: string; +}; + // @public export const CardBody: ForwardRefExoticComponent< CardBodyProps & RefAttributes @@ -595,6 +601,13 @@ export interface CardBodyProps extends CardBodyOwnProps, React.HTMLAttributes {} +// @public (undocumented) +export type CardButtonVariant = { + onPress: () => void; + href?: never; + label: string; +}; + // @public export const CardDefinition: { readonly styles: { @@ -602,10 +615,14 @@ export const CardDefinition: { }; readonly classNames: { readonly root: 'bui-Card'; + readonly overlay: 'bui-CardOverlay'; }; readonly propDefs: { readonly children: {}; readonly className: {}; + readonly onPress: {}; + readonly href: {}; + readonly label: {}; }; }; @@ -670,15 +687,32 @@ export interface CardHeaderProps React.HTMLAttributes {} // @public (undocumented) -export type CardOwnProps = { - children?: ReactNode; - className?: string; +export type CardLinkVariant = { + href: string; + onPress?: never; + label?: string; }; // @public -export interface CardProps - extends CardOwnProps, - React.HTMLAttributes {} +export type CardOwnProps = { + children?: ReactNode; + className?: string; + onPress?: () => void; + href?: string; + label?: string; +}; + +// @public +export type CardProps = CardBaseProps & + Omit, 'onPress'> & + (CardButtonVariant | CardLinkVariant | CardStaticVariant); + +// @public (undocumented) +export type CardStaticVariant = { + onPress?: never; + href?: never; + label?: never; +}; // @public (undocumented) export const Cell: { diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index 445873f6ba..5e59d1e930 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -27,6 +27,48 @@ overflow: hidden; min-height: 0; width: 100%; + position: relative; + } + + .bui-CardOverlay { + position: absolute; + inset: 0; + z-index: 1; + background: transparent; + cursor: pointer; + border-radius: inherit; + border: none; + padding: 0; + appearance: none; + display: block; + width: 100%; + + &:focus-visible { + outline: 2px solid var(--bui-ring); + outline-offset: -2px; + } + + &:hover::after, + &[data-focused]::after { + content: ''; + position: absolute; + inset: 0; + background: color-mix(in srgb, currentColor 5%, transparent); + border-radius: inherit; + pointer-events: none; + } + } + + /* + * Interactive elements inside the card must sit above the overlay so they + * remain independently clickable. The overlay handles the rest of the surface. + */ + .bui-Card[data-interactive] + :is(button, a[href], [role='button'], [role='link'], input, select, textarea):not( + .bui-CardOverlay + ) { + position: relative; + z-index: 2; } .bui-CardBody { diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index 3ddde49806..41b43b89a4 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -231,6 +231,94 @@ export const BgOnProviders = meta.story({ ), }); +export const Interactive = meta.story({ + render: () => ( + alert('Card pressed')} + label="View component details" + > + + Interactive Card + + + + Click anywhere on this card to trigger the press handler. The entire + card surface is interactive. + + + + + Click to interact + + + + ), +}); + +export const InteractiveAsLink = meta.story({ + render: () => ( + + + Link Card + + + + This card navigates to a URL when clicked. The entire card surface + acts as a link. + + + + + Opens backstage.io + + + + ), +}); + +export const InteractiveWithNestedButtons = meta.story({ + render: () => ( + alert('Card pressed')} + label="View plugin details" + > + + Card with Actions + + + + Clicking the card background triggers the card press handler. The + buttons below remain independently interactive. + + + + + + + + + + ), +}); + export const CustomCardWithBox = meta.story({ render: () => ( diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index 5eb09f2edc..cd09daf576 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -15,6 +15,7 @@ */ import { forwardRef } from 'react'; +import { Button as RAButton, Link as RALink } from 'react-aria-components'; import { useDefinition } from '../../hooks/useDefinition'; import { CardDefinition, @@ -23,6 +24,7 @@ import { CardFooterDefinition, } from './definition'; import type { + CardOwnProps, CardProps, CardHeaderProps, CardBodyProps, @@ -38,18 +40,31 @@ import { Box } from '../Box/Box'; export const Card = forwardRef((props, ref) => { const { ownProps, restProps, dataAttributes } = useDefinition( CardDefinition, - props, + props as CardOwnProps & + Omit, 'onPress'>, ); - const { classes, children } = ownProps; + const { classes, children, onPress, href, label } = ownProps; + const isInteractive = !!(onPress || href); return ( + {href && ( + + )} + {onPress && !href && ( + + )} {children} ); diff --git a/packages/ui/src/components/Card/definition.ts b/packages/ui/src/components/Card/definition.ts index 6493d01839..814b42aa9d 100644 --- a/packages/ui/src/components/Card/definition.ts +++ b/packages/ui/src/components/Card/definition.ts @@ -31,10 +31,14 @@ export const CardDefinition = defineComponent()({ styles, classNames: { root: 'bui-Card', + overlay: 'bui-CardOverlay', }, propDefs: { children: {}, className: {}, + onPress: {}, + href: {}, + label: {}, }, }); diff --git a/packages/ui/src/components/Card/types.ts b/packages/ui/src/components/Card/types.ts index e31b2cfda2..a00e0b797f 100644 --- a/packages/ui/src/components/Card/types.ts +++ b/packages/ui/src/components/Card/types.ts @@ -16,10 +16,44 @@ import type { ReactNode } from 'react'; -/** @public */ +/** + * Flat own-props shape used by the component definition system. + * @public + */ export type CardOwnProps = { children?: ReactNode; className?: string; + onPress?: () => void; + href?: string; + label?: string; +}; + +/** @public */ +export type CardBaseProps = { children?: ReactNode; className?: string }; + +/** @public */ +export type CardButtonVariant = { + /** Handler called when the card is pressed. Makes the card interactive as a button. */ + onPress: () => void; + href?: never; + /** Accessible label announced by screen readers for the interactive card. */ + label: string; +}; + +/** @public */ +export type CardLinkVariant = { + /** URL to navigate to. Makes the card interactive as a link. */ + href: string; + onPress?: never; + /** Accessible label announced by screen readers for the interactive card. */ + label?: string; +}; + +/** @public */ +export type CardStaticVariant = { + onPress?: never; + href?: never; + label?: never; }; /** @@ -27,9 +61,9 @@ export type CardOwnProps = { * * @public */ -export interface CardProps - extends CardOwnProps, - React.HTMLAttributes {} +export type CardProps = CardBaseProps & + Omit, 'onPress'> & + (CardButtonVariant | CardLinkVariant | CardStaticVariant); /** @public */ export type CardHeaderOwnProps = { From 90b1cfc8a0f88723fbb85a4478bdaf5e9f39f479 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 4 Mar 2026 16:59:25 +0000 Subject: [PATCH 2/4] Fix for scrollable body section Signed-off-by: Charles de Dreuille --- .../ui/src/components/Card/Card.module.css | 45 ++++++++++++++++--- .../ui/src/components/Card/Card.stories.tsx | 33 ++++++++++++++ packages/ui/src/components/Card/Card.tsx | 36 +++++++++++++-- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index 5e59d1e930..075a8dc556 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -30,12 +30,29 @@ position: relative; } + /* + * Cursor and hover tint are applied at the card level so they cover the + * entire surface — including CardBody which sits above the overlay. + */ + .bui-Card[data-interactive] { + cursor: pointer; + + &:hover::after { + content: ''; + position: absolute; + inset: 0; + background: color-mix(in srgb, currentColor 5%, transparent); + border-radius: inherit; + pointer-events: none; + z-index: 3; + } + } + .bui-CardOverlay { position: absolute; inset: 0; z-index: 1; background: transparent; - cursor: pointer; border-radius: inherit; border: none; padding: 0; @@ -48,7 +65,10 @@ outline-offset: -2px; } - &:hover::after, + /* + * Keep focus tint for keyboard navigation (hover tint has moved to the + * card container above). + */ &[data-focused]::after { content: ''; position: absolute; @@ -60,13 +80,24 @@ } /* - * Interactive elements inside the card must sit above the overlay so they - * remain independently clickable. The overlay handles the rest of the surface. + * CardBody and interactive elements must sit above the overlay (z-index: 1) + * so that: + * - scroll events reach CardBody's overflow container + * - buttons/links/inputs remain independently clickable + * Text clicks in CardBody that bypass the overlay are caught by the + * container-level onClick in Card.tsx. */ .bui-Card[data-interactive] - :is(button, a[href], [role='button'], [role='link'], input, select, textarea):not( - .bui-CardOverlay - ) { + :is( + button, + a[href], + [role='button'], + [role='link'], + input, + select, + textarea, + .bui-CardBody + ):not(.bui-CardOverlay) { position: relative; z-index: 2; } diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index 41b43b89a4..e51a2c748b 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -231,6 +231,39 @@ export const BgOnProviders = meta.story({ ), }); +export const InteractiveWithScrollableBody = meta.story({ + render: () => ( + alert('Card pressed')} + label="View details" + > + + Scrollable Interactive Card + + + + This is the first paragraph of a long body text that demonstrates how + the Card component handles extensive content. The card should adjust + accordingly to display all the text properly while maintaining its + structure. + + + Here's a second paragraph that adds more content to our card body. + Having multiple paragraphs helps to visualize how spacing works within + the card component. + + + This third paragraph continues to add more text to ensure we have a + proper demonstration of a card with significant content. This makes it + easier to test scrolling behavior and overall layout when content + exceeds the initial view. + + + + ), +}); + export const Interactive = meta.story({ render: () => ( ((props, ref) => { const { classes, children, onPress, href, label } = ownProps; const isInteractive = !!(onPress || href); + // CardBody sits above the overlay (z-index: 2) so that scroll events reach + // its overflow container. As a result, text clicks inside CardBody bypass + // the overlay and bubble up to here instead. We fire the card action only + // when the click did not originate from a nested interactive element. + const { onClick: userOnClick, ...restPropsWithoutClick } = + restProps as React.HTMLAttributes; + + const handleContainerClick = useCallback( + (e: React.MouseEvent) => { + userOnClick?.(e); + if (!isInteractive) return; + const target = e.target as HTMLElement; + if ( + target.closest?.( + 'button, a[href], input, select, textarea, [role="button"], [role="link"]', + ) + ) + return; + onPress?.(); + }, + [userOnClick, isInteractive, onPress], + ); + return ( ((props, ref) => { className={classes.root} data-interactive={isInteractive || undefined} {...dataAttributes} - {...restProps} + {...restPropsWithoutClick} + onClick={handleContainerClick} > {href && ( - + e.stopPropagation()} + /> )} {onPress && !href && ( e.stopPropagation()} /> )} {children} From 0c772a3223d0eed2e898d6e46334f7f8ffb04e87 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 4 Mar 2026 19:40:25 +0000 Subject: [PATCH 3/4] Card styles updates Signed-off-by: Charles de Dreuille --- packages/ui/report.api.md | 9 +- .../ui/src/components/Card/Card.module.css | 120 +++++++++-- .../ui/src/components/Card/Card.stories.tsx | 201 ++++++++++-------- packages/ui/src/components/Card/Card.tsx | 36 +--- packages/ui/src/components/Card/definition.ts | 3 + packages/ui/src/components/Card/types.ts | 7 +- 6 files changed, 243 insertions(+), 133 deletions(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 503ec19414..31617a36bb 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -623,6 +623,9 @@ export const CardDefinition: { readonly onPress: {}; readonly href: {}; readonly label: {}; + readonly target: {}; + readonly rel: {}; + readonly download: {}; }; }; @@ -690,7 +693,8 @@ export interface CardHeaderProps export type CardLinkVariant = { href: string; onPress?: never; - label?: string; + label: string; + target?: string; }; // @public @@ -700,6 +704,9 @@ export type CardOwnProps = { onPress?: () => void; href?: string; label?: string; + target?: string; + rel?: string; + download?: boolean | string; }; // @public diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index 075a8dc556..978731344d 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -20,24 +20,39 @@ .bui-Card { display: flex; flex-direction: column; - gap: var(--bui-space-3); border-radius: var(--bui-radius-3); - padding-block: var(--bui-space-3); color: var(--bui-fg-primary); - overflow: hidden; + overflow: auto; min-height: 0; width: 100%; position: relative; + padding: var(--bui-space-3); + } + + .bui-Card[data-bg='neutral-1'] { + --bui-card-bg: var(--bui-bg-neutral-1); + } + + .bui-Card[data-bg='neutral-2'] { + --bui-card-bg: var(--bui-bg-neutral-2); + } + + .bui-Card[data-bg='neutral-3'] { + --bui-card-bg: var(--bui-bg-neutral-3); + } + + .bui-Card:has(.bui-CardHeader, .bui-CardBody, .bui-CardFooter) { + padding: 0; } /* * Cursor and hover tint are applied at the card level so they cover the - * entire surface — including CardBody which sits above the overlay. + * entire surface. The overlay inherits the cursor via cursor: inherit. */ .bui-Card[data-interactive] { cursor: pointer; - &:hover::after { + &::after { content: ''; position: absolute; inset: 0; @@ -45,6 +60,12 @@ border-radius: inherit; pointer-events: none; z-index: 3; + opacity: 0; + transition: opacity 200ms ease-in-out; + } + + &:hover::after { + opacity: 1; } } @@ -59,6 +80,7 @@ appearance: none; display: block; width: 100%; + cursor: inherit; &:focus-visible { outline: 2px solid var(--bui-ring); @@ -80,12 +102,12 @@ } /* - * CardBody and interactive elements must sit above the overlay (z-index: 1) - * so that: - * - scroll events reach CardBody's overflow container - * - buttons/links/inputs remain independently clickable - * Text clicks in CardBody that bypass the overlay are caught by the - * container-level onClick in Card.tsx. + * Nested interactive elements must sit above the overlay (z-index: 1) so + * that buttons, links, and inputs remain independently clickable. + * CardBody is intentionally excluded: it sits beneath the overlay so that + * card-surface clicks route through the overlay natively (preserving link + * semantics such as target and rel). Scroll is not supported for interactive + * cards as a result. */ .bui-Card[data-interactive] :is( @@ -96,24 +118,94 @@ input, select, textarea, - .bui-CardBody + .bui-Button ):not(.bui-CardOverlay) { position: relative; z-index: 2; } + /* + * The bottom scroll-shadow pseudo-element uses a reversed scroll-driven + * animation whose fill-before state is opacity: 1. When the card has no + * height constraint (nothing to scroll), the animation is permanently stuck + * in that fill-before state, making the shadow visible even though there is + * no overflow. Interactive cards never scroll, so we suppress it entirely. + * The selector mirrors .bui-Card:has(.bui-CardFooter) .bui-CardBody::after + * with an added attribute to win the specificity race. + */ + .bui-Card[data-interactive]:has(.bui-CardFooter) .bui-CardBody::after { + display: none; + } + + .bui-CardHeader { + padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-3); + } + .bui-CardBody { flex: 1; min-height: 0; overflow: auto; padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-3); } - .bui-CardHeader { - padding-inline: var(--bui-space-3); + @keyframes bui-card-body-shadow { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .bui-Card:has(.bui-CardHeader) .bui-CardBody { + padding-block-start: 0; + + &::before { + content: ''; + position: sticky; + top: 0; + display: block; + height: 1.5rem; + margin-bottom: -1.5rem; + background: linear-gradient( + to bottom, + var(--bui-card-bg), + rgb(from var(--bui-card-bg) r g b / 0) + ); + pointer-events: none; + opacity: 0; + animation: bui-card-body-shadow linear both; + animation-timeline: scroll(); + animation-range: 0px 2.5rem; + } + } + + .bui-Card:has(.bui-CardFooter) .bui-CardBody { + padding-block-end: 0; + + &::after { + content: ''; + position: sticky; + bottom: 0; + display: block; + height: 1.5rem; + margin-top: -1.5rem; + background: linear-gradient( + to top, + var(--bui-card-bg), + rgb(from var(--bui-card-bg) r g b / 0) + ); + pointer-events: none; + animation: bui-card-body-shadow linear both reverse; + animation-timeline: scroll(); + animation-range: calc(100% - 2.5rem) 100%; + } } .bui-CardFooter { padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-3); } } diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index e51a2c748b..117859a1bb 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -27,49 +27,64 @@ const meta = preview.meta({ }); export const Default = meta.story({ + render: args => Hello world, +}); + +export const DefaultWithHeader = meta.story({ render: args => ( Header Body - Footer ), }); -export const CustomSize = Default.extend({ - args: { - style: { - width: '300px', - height: '200px', - }, - }, +const content = ( + <> + + This is the first paragraph of a long body text that demonstrates how the + Card component handles extensive content. The card should adjust + accordingly to display all the text properly while maintaining its + structure. + + + Here's a second paragraph that adds more content to our card body. Having + multiple paragraphs helps to visualize how spacing works within the card + component. + + + This third paragraph continues to add more text to ensure we have a proper + demonstration of a card with significant content. This makes it easier to + test scrolling behavior and overall layout when content exceeds the + initial view. + + +); + +export const LongBody = meta.story({ + render: () => ( + {content} + ), }); -export const WithLongBody = meta.story({ +export const LongBodyHeader = meta.story({ render: () => ( Header - - - This is the first paragraph of a long body text that demonstrates how - the Card component handles extensive content. The card should adjust - accordingly to display all the text properly while maintaining its - structure. - - - Here's a second paragraph that adds more content to our card body. - Having multiple paragraphs helps to visualize how spacing works within - the card component. - - - This third paragraph continues to add more text to ensure we have a - proper demonstration of a card with significant content. This makes it - easier to test scrolling behavior and overall layout when content - exceeds the initial view. - - + {content} + + ), +}); + +export const LongBodyHeaderFooter = meta.story({ + render: () => ( + + + Header + + {content} Footer @@ -77,7 +92,7 @@ export const WithLongBody = meta.story({ ), }); -const ListRow = ({ children }: { children: React.ReactNode }) => { +const ListRowComponent = ({ children }: { children: React.ReactNode }) => { return (
{ paddingInline: 'var(--bui-space-3)', borderRadius: 'var(--bui-radius-2)', fontSize: 'var(--bui-font-size-3)', - marginBottom: 'var(--bui-space-1)', }} > {children} @@ -97,29 +111,76 @@ const ListRow = ({ children }: { children: React.ReactNode }) => { ); }; -export const WithListRow = meta.story({ +const listRowContent = ( + + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + +); + +export const ListRow = meta.story({ + render: () => ( + + {listRowContent} + + ), +}); + +export const ListRowHeader = meta.story({ + render: () => ( + + + Header + + {listRowContent} + + ), +}); + +export const ListRowFooter = meta.story({ + render: () => ( + + {listRowContent} + + Footer + + + ), +}); + +export const ListRowHeaderFooter = meta.story({ render: () => ( Header - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world + + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Footer @@ -231,39 +292,6 @@ export const BgOnProviders = meta.story({ ), }); -export const InteractiveWithScrollableBody = meta.story({ - render: () => ( - alert('Card pressed')} - label="View details" - > - - Scrollable Interactive Card - - - - This is the first paragraph of a long body text that demonstrates how - the Card component handles extensive content. The card should adjust - accordingly to display all the text properly while maintaining its - structure. - - - Here's a second paragraph that adds more content to our card body. - Having multiple paragraphs helps to visualize how spacing works within - the card component. - - - This third paragraph continues to add more text to ensure we have a - proper demonstration of a card with significant content. This makes it - easier to test scrolling behavior and overall layout when content - exceeds the initial view. - - - - ), -}); - export const Interactive = meta.story({ render: () => ( - - Link Card - + Link Card This card navigates to a URL when clicked. The entire card surface acts as a link. - - - Opens backstage.io - - + Opens backstage.io ), }); diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index 2a3e0f5124..b948e19df9 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { forwardRef, useCallback } from 'react'; +import { forwardRef } from 'react'; import { Button as RAButton, Link as RALink } from 'react-aria-components'; import { useDefinition } from '../../hooks/useDefinition'; import { @@ -43,32 +43,10 @@ export const Card = forwardRef((props, ref) => { props as CardOwnProps & Omit, 'onPress'>, ); - const { classes, children, onPress, href, label } = ownProps; + const { classes, children, onPress, href, label, target, rel, download } = + ownProps; const isInteractive = !!(onPress || href); - // CardBody sits above the overlay (z-index: 2) so that scroll events reach - // its overflow container. As a result, text clicks inside CardBody bypass - // the overlay and bubble up to here instead. We fire the card action only - // when the click did not originate from a nested interactive element. - const { onClick: userOnClick, ...restPropsWithoutClick } = - restProps as React.HTMLAttributes; - - const handleContainerClick = useCallback( - (e: React.MouseEvent) => { - userOnClick?.(e); - if (!isInteractive) return; - const target = e.target as HTMLElement; - if ( - target.closest?.( - 'button, a[href], input, select, textarea, [role="button"], [role="link"]', - ) - ) - return; - onPress?.(); - }, - [userOnClick, isInteractive, onPress], - ); - return ( ((props, ref) => { className={classes.root} data-interactive={isInteractive || undefined} {...dataAttributes} - {...restPropsWithoutClick} - onClick={handleContainerClick} + {...restProps} > {href && ( e.stopPropagation()} /> )} {onPress && !href && ( @@ -92,7 +71,6 @@ export const Card = forwardRef((props, ref) => { className={classes.overlay} onPress={onPress} aria-label={label} - onClick={e => e.stopPropagation()} /> )} {children} diff --git a/packages/ui/src/components/Card/definition.ts b/packages/ui/src/components/Card/definition.ts index 814b42aa9d..3408828a4d 100644 --- a/packages/ui/src/components/Card/definition.ts +++ b/packages/ui/src/components/Card/definition.ts @@ -39,6 +39,9 @@ export const CardDefinition = defineComponent()({ onPress: {}, href: {}, label: {}, + target: {}, + rel: {}, + download: {}, }, }); diff --git a/packages/ui/src/components/Card/types.ts b/packages/ui/src/components/Card/types.ts index a00e0b797f..f70f4b7c3b 100644 --- a/packages/ui/src/components/Card/types.ts +++ b/packages/ui/src/components/Card/types.ts @@ -26,6 +26,9 @@ export type CardOwnProps = { onPress?: () => void; href?: string; label?: string; + target?: string; + rel?: string; + download?: boolean | string; }; /** @public */ @@ -46,7 +49,9 @@ export type CardLinkVariant = { href: string; onPress?: never; /** Accessible label announced by screen readers for the interactive card. */ - label?: string; + label: string; + /** Specifies where to open the linked URL (e.g. `_blank` for a new tab). */ + target?: string; }; /** @public */ From 16d424276a4cfde6e1929c0ab9e040acca2f1239 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 4 Mar 2026 20:24:12 +0000 Subject: [PATCH 4/4] Improve types Signed-off-by: Charles de Dreuille --- docs-ui/src/app/components/card/page.mdx | 2 +- .../app/components/card/props-definition.ts | 2 +- packages/ui/report.api.md | 31 ++++++++----- .../ui/src/components/Card/Card.module.css | 1 + packages/ui/src/components/Card/Card.tsx | 4 +- packages/ui/src/components/Card/types.ts | 45 ++++++++++++------- 6 files changed, 53 insertions(+), 32 deletions(-) diff --git a/docs-ui/src/app/components/card/page.mdx b/docs-ui/src/app/components/card/page.mdx index df4d531604..8df6eccb40 100644 --- a/docs-ui/src/app/components/card/page.mdx +++ b/docs-ui/src/app/components/card/page.mdx @@ -110,7 +110,7 @@ Pass `onPress` and a `label` (used as the accessible name for screen readers) to ### Link -Pass `href` to make the card surface navigate to a URL. The `label` prop is optional but recommended for accessibility when the card content alone may not sufficiently describe the destination. +Pass `href` to make the card surface navigate to a URL. `label` is required — it provides the accessible name for the invisible overlay link read by screen readers. = { type: 'string', responsive: false, description: - 'Accessible label announced by screen readers for the interactive overlay. Required when onPress is provided.', + 'Accessible label announced by screen readers for the interactive overlay. Required when onPress or href is provided.', }, ...classNamePropDefs, ...stylePropDefs, diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 31617a36bb..7e8c8833ee 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -603,9 +603,12 @@ export interface CardBodyProps // @public (undocumented) export type CardButtonVariant = { - onPress: () => void; + onPress: NonNullable; href?: never; label: string; + target?: never; + rel?: never; + download?: never; }; // @public @@ -695,20 +698,23 @@ export type CardLinkVariant = { onPress?: never; label: string; target?: string; -}; - -// @public -export type CardOwnProps = { - children?: ReactNode; - className?: string; - onPress?: () => void; - href?: string; - label?: string; - target?: string; rel?: string; download?: boolean | string; }; +// @public +export type CardOwnProps = Pick< + CardBaseProps & (CardButtonVariant | CardLinkVariant | CardStaticVariant), + | 'children' + | 'className' + | 'onPress' + | 'href' + | 'label' + | 'target' + | 'rel' + | 'download' +>; + // @public export type CardProps = CardBaseProps & Omit, 'onPress'> & @@ -719,6 +725,9 @@ export type CardStaticVariant = { onPress?: never; href?: never; label?: never; + target?: never; + rel?: never; + download?: never; }; // @public (undocumented) diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index 978731344d..f0aaa6831f 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -51,6 +51,7 @@ */ .bui-Card[data-interactive] { cursor: pointer; + overflow: hidden; &::after { content: ''; diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index b948e19df9..2527a3a46b 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -24,7 +24,6 @@ import { CardFooterDefinition, } from './definition'; import type { - CardOwnProps, CardProps, CardHeaderProps, CardBodyProps, @@ -40,8 +39,7 @@ import { Box } from '../Box/Box'; export const Card = forwardRef((props, ref) => { const { ownProps, restProps, dataAttributes } = useDefinition( CardDefinition, - props as CardOwnProps & - Omit, 'onPress'>, + props, ); const { classes, children, onPress, href, label, target, rel, download } = ownProps; diff --git a/packages/ui/src/components/Card/types.ts b/packages/ui/src/components/Card/types.ts index f70f4b7c3b..f1e91055e9 100644 --- a/packages/ui/src/components/Card/types.ts +++ b/packages/ui/src/components/Card/types.ts @@ -15,21 +15,7 @@ */ import type { ReactNode } from 'react'; - -/** - * Flat own-props shape used by the component definition system. - * @public - */ -export type CardOwnProps = { - children?: ReactNode; - className?: string; - onPress?: () => void; - href?: string; - label?: string; - target?: string; - rel?: string; - download?: boolean | string; -}; +import type { ButtonProps as RAButtonProps } from 'react-aria-components'; /** @public */ export type CardBaseProps = { children?: ReactNode; className?: string }; @@ -37,10 +23,13 @@ export type CardBaseProps = { children?: ReactNode; className?: string }; /** @public */ export type CardButtonVariant = { /** Handler called when the card is pressed. Makes the card interactive as a button. */ - onPress: () => void; + onPress: NonNullable; href?: never; /** Accessible label announced by screen readers for the interactive card. */ label: string; + target?: never; + rel?: never; + download?: never; }; /** @public */ @@ -52,6 +41,10 @@ export type CardLinkVariant = { label: string; /** Specifies where to open the linked URL (e.g. `_blank` for a new tab). */ target?: string; + /** Relationship between the current document and the linked URL (e.g. `noopener`). */ + rel?: string; + /** Prompts the user to save the linked URL. Pass `true` for default filename or a string for a custom filename. */ + download?: boolean | string; }; /** @public */ @@ -59,6 +52,9 @@ export type CardStaticVariant = { onPress?: never; href?: never; label?: never; + target?: never; + rel?: never; + download?: never; }; /** @@ -70,6 +66,23 @@ export type CardProps = CardBaseProps & Omit, 'onPress'> & (CardButtonVariant | CardLinkVariant | CardStaticVariant); +/** + * Flat own-props shape used by the component definition system. + * Derived from the Card variant types so it automatically stays in sync with CardProps. + * @public + */ +export type CardOwnProps = Pick< + CardBaseProps & (CardButtonVariant | CardLinkVariant | CardStaticVariant), + | 'children' + | 'className' + | 'onPress' + | 'href' + | 'label' + | 'target' + | 'rel' + | 'download' +>; + /** @public */ export type CardHeaderOwnProps = { children?: ReactNode;