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 = {