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..8df6eccb40 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. `label` is required — it provides the accessible name for the invisible overlay link read by screen readers. + +} + 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..7e68d890b6 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 or href 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..7e8c8833ee 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,16 @@ export interface CardBodyProps extends CardBodyOwnProps, React.HTMLAttributes {} +// @public (undocumented) +export type CardButtonVariant = { + onPress: NonNullable; + href?: never; + label: string; + target?: never; + rel?: never; + download?: never; +}; + // @public export const CardDefinition: { readonly styles: { @@ -602,10 +618,17 @@ 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: {}; + readonly target: {}; + readonly rel: {}; + readonly download: {}; }; }; @@ -670,15 +693,42 @@ export interface CardHeaderProps React.HTMLAttributes {} // @public (undocumented) -export type CardOwnProps = { - children?: ReactNode; - className?: string; +export type CardLinkVariant = { + href: string; + onPress?: never; + label: string; + target?: string; + rel?: string; + download?: boolean | string; }; // @public -export interface CardProps - extends CardOwnProps, - React.HTMLAttributes {} +export type CardOwnProps = Pick< + CardBaseProps & (CardButtonVariant | CardLinkVariant | CardStaticVariant), + | 'children' + | 'className' + | 'onPress' + | 'href' + | 'label' + | 'target' + | 'rel' + | 'download' +>; + +// @public +export type CardProps = CardBaseProps & + Omit, 'onPress'> & + (CardButtonVariant | CardLinkVariant | CardStaticVariant); + +// @public (undocumented) +export type CardStaticVariant = { + onPress?: never; + href?: never; + label?: never; + target?: never; + rel?: never; + download?: 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..f0aaa6831f 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -20,13 +20,127 @@ .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. The overlay inherits the cursor via cursor: inherit. + */ + .bui-Card[data-interactive] { + cursor: pointer; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: color-mix(in srgb, currentColor 5%, transparent); + border-radius: inherit; + pointer-events: none; + z-index: 3; + opacity: 0; + transition: opacity 200ms ease-in-out; + } + + &:hover::after { + opacity: 1; + } + } + + .bui-CardOverlay { + position: absolute; + inset: 0; + z-index: 1; + background: transparent; + border-radius: inherit; + border: none; + padding: 0; + appearance: none; + display: block; + width: 100%; + cursor: inherit; + + &:focus-visible { + outline: 2px solid var(--bui-ring); + outline-offset: -2px; + } + + /* + * Keep focus tint for keyboard navigation (hover tint has moved to the + * card container above). + */ + &[data-focused]::after { + content: ''; + position: absolute; + inset: 0; + background: color-mix(in srgb, currentColor 5%, transparent); + border-radius: inherit; + pointer-events: none; + } + } + + /* + * 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( + button, + a[href], + [role='button'], + [role='link'], + input, + select, + textarea, + .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 { @@ -34,13 +148,65 @@ 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 3ddde49806..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,6 +292,91 @@ 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..2527a3a46b 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, @@ -40,16 +41,36 @@ export const Card = forwardRef((props, ref) => { CardDefinition, props, ); - const { classes, children } = ownProps; + const { classes, children, onPress, href, label, target, rel, download } = + 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..3408828a4d 100644 --- a/packages/ui/src/components/Card/definition.ts +++ b/packages/ui/src/components/Card/definition.ts @@ -31,10 +31,17 @@ export const CardDefinition = defineComponent()({ styles, classNames: { root: 'bui-Card', + overlay: 'bui-CardOverlay', }, propDefs: { children: {}, className: {}, + 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 e31b2cfda2..f1e91055e9 100644 --- a/packages/ui/src/components/Card/types.ts +++ b/packages/ui/src/components/Card/types.ts @@ -15,11 +15,46 @@ */ import type { ReactNode } from 'react'; +import type { ButtonProps as RAButtonProps } from 'react-aria-components'; /** @public */ -export type CardOwnProps = { - children?: ReactNode; - className?: string; +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: NonNullable; + href?: never; + /** Accessible label announced by screen readers for the interactive card. */ + label: string; + target?: never; + rel?: never; + download?: never; +}; + +/** @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; + /** 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 */ +export type CardStaticVariant = { + onPress?: never; + href?: never; + label?: never; + target?: never; + rel?: never; + download?: never; }; /** @@ -27,9 +62,26 @@ export type CardOwnProps = { * * @public */ -export interface CardProps - extends CardOwnProps, - React.HTMLAttributes {} +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 = {