Add new Interactive card

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-03-04 15:24:23 +00:00
parent d757071651
commit db927510dc
11 changed files with 417 additions and 13 deletions
+40 -6
View File
@@ -565,6 +565,12 @@ export const Card: ForwardRefExoticComponent<
CardProps & RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export type CardBaseProps = {
children?: ReactNode;
className?: string;
};
// @public
export const CardBody: ForwardRefExoticComponent<
CardBodyProps & RefAttributes<HTMLDivElement>
@@ -595,6 +601,13 @@ export interface CardBodyProps
extends CardBodyOwnProps,
React.HTMLAttributes<HTMLDivElement> {}
// @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<HTMLDivElement> {}
// @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<HTMLDivElement> {}
export type CardOwnProps = {
children?: ReactNode;
className?: string;
onPress?: () => void;
href?: string;
label?: string;
};
// @public
export type CardProps = CardBaseProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onPress'> &
(CardButtonVariant | CardLinkVariant | CardStaticVariant);
// @public (undocumented)
export type CardStaticVariant = {
onPress?: never;
href?: never;
label?: never;
};
// @public (undocumented)
export const Cell: {
@@ -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 {
@@ -231,6 +231,94 @@ export const BgOnProviders = meta.story({
),
});
export const Interactive = meta.story({
render: () => (
<Card
style={{ width: '300px' }}
onPress={() => alert('Card pressed')}
label="View component details"
>
<CardHeader>
<Text weight="bold">Interactive Card</Text>
</CardHeader>
<CardBody>
<Text>
Click anywhere on this card to trigger the press handler. The entire
card surface is interactive.
</Text>
</CardBody>
<CardFooter>
<Text variant="body-small" color="secondary">
Click to interact
</Text>
</CardFooter>
</Card>
),
});
export const InteractiveAsLink = meta.story({
render: () => (
<Card
style={{ width: '300px' }}
href="https://backstage.io"
label="Open Backstage documentation"
>
<CardHeader>
<Text weight="bold">Link Card</Text>
</CardHeader>
<CardBody>
<Text>
This card navigates to a URL when clicked. The entire card surface
acts as a link.
</Text>
</CardBody>
<CardFooter>
<Text variant="body-small" color="secondary">
Opens backstage.io
</Text>
</CardFooter>
</Card>
),
});
export const InteractiveWithNestedButtons = meta.story({
render: () => (
<Card
style={{ width: '300px' }}
onPress={() => alert('Card pressed')}
label="View plugin details"
>
<CardHeader>
<Text weight="bold">Card with Actions</Text>
</CardHeader>
<CardBody>
<Text>
Clicking the card background triggers the card press handler. The
buttons below remain independently interactive.
</Text>
</CardBody>
<CardFooter>
<Flex gap="2">
<Button
size="small"
variant="secondary"
onPress={() => alert('Primary action')}
>
Primary
</Button>
<Button
size="small"
variant="tertiary"
onPress={() => alert('Secondary action')}
>
Secondary
</Button>
</Flex>
</CardFooter>
</Card>
),
});
export const CustomCardWithBox = meta.story({
render: () => (
<Flex direction="column" gap="4">
+17 -2
View File
@@ -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<HTMLDivElement, CardProps>((props, ref) => {
const { ownProps, restProps, dataAttributes } = useDefinition(
CardDefinition,
props,
props as CardOwnProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onPress'>,
);
const { classes, children } = ownProps;
const { classes, children, onPress, href, label } = ownProps;
const isInteractive = !!(onPress || href);
return (
<Box
bg="neutral"
ref={ref}
className={classes.root}
data-interactive={isInteractive || undefined}
{...dataAttributes}
{...restProps}
>
{href && (
<RALink className={classes.overlay} href={href} aria-label={label} />
)}
{onPress && !href && (
<RAButton
className={classes.overlay}
onPress={onPress}
aria-label={label}
/>
)}
{children}
</Box>
);
@@ -31,10 +31,14 @@ export const CardDefinition = defineComponent<CardOwnProps>()({
styles,
classNames: {
root: 'bui-Card',
overlay: 'bui-CardOverlay',
},
propDefs: {
children: {},
className: {},
onPress: {},
href: {},
label: {},
},
});
+38 -4
View File
@@ -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<HTMLDivElement> {}
export type CardProps = CardBaseProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onPress'> &
(CardButtonVariant | CardLinkVariant | CardStaticVariant);
/** @public */
export type CardHeaderOwnProps = {