Merge pull request #33116 from backstage/interactive-cards
feat(ui): add interactive overlay to Card component
This commit is contained in:
@@ -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.
|
||||
@@ -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 (
|
||||
<Card
|
||||
style={{ width: '300px' }}
|
||||
onPress={() => {}}
|
||||
label="View component details"
|
||||
>
|
||||
<CardHeader>Interactive Card</CardHeader>
|
||||
<CardBody>
|
||||
Click anywhere on this card to trigger the press handler.
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Text variant="body-small" color="secondary">
|
||||
Click to interact
|
||||
</Text>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const InteractiveLink = () => {
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '300px' }}
|
||||
href="https://backstage.io"
|
||||
label="Open Backstage documentation"
|
||||
>
|
||||
<CardHeader>Link Card</CardHeader>
|
||||
<CardBody>This card navigates to a URL when clicked.</CardBody>
|
||||
<CardFooter>
|
||||
<Text variant="body-small" color="secondary">
|
||||
Opens backstage.io
|
||||
</Text>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const InteractiveWithNestedButtons = () => {
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '300px' }}
|
||||
onPress={() => {}}
|
||||
label="View plugin details"
|
||||
>
|
||||
<CardHeader>Card with Actions</CardHeader>
|
||||
<CardBody>
|
||||
Clicking the card background triggers the card press handler. The
|
||||
buttons below remain independently interactive.
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Flex gap="2">
|
||||
<Button size="small" variant="secondary" onPress={() => {}}>
|
||||
Primary
|
||||
</Button>
|
||||
<Button size="small" variant="tertiary" onPress={() => {}}>
|
||||
Secondary
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithLongBody = () => {
|
||||
return (
|
||||
<Card style={{ width: '300px', height: '200px' }}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
layout="side-by-side"
|
||||
preview={<InteractiveButton />}
|
||||
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.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
layout="side-by-side"
|
||||
preview={<InteractiveLink />}
|
||||
code={interactiveLinkSnippet}
|
||||
/>
|
||||
|
||||
### With nested buttons
|
||||
|
||||
Buttons and links inside the card remain independently interactive. Clicking them does not trigger the card's `onPress` handler.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
layout="side-by-side"
|
||||
preview={<InteractiveWithNestedButtons />}
|
||||
code={interactiveWithNestedButtonsSnippet}
|
||||
/>
|
||||
|
||||
<Theming definition={CardDefinition} />
|
||||
|
||||
<ChangelogComponent component="card" />
|
||||
|
||||
@@ -15,6 +15,25 @@ const optionalChildrenPropDef: Record<string, PropDef> = {
|
||||
|
||||
export const cardPropDefs: Record<string, PropDef> = {
|
||||
...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,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,50 @@ export const headerAndBodySnippet = `<Card style={{ width: '300px', height: '200
|
||||
<CardBody>Body content without a footer</CardBody>
|
||||
</Card>`;
|
||||
|
||||
export const interactiveButtonSnippet = `<Card
|
||||
style={{ width: '300px' }}
|
||||
onPress={() => console.log('Card pressed')}
|
||||
label="View component details"
|
||||
>
|
||||
<CardHeader>Interactive Card</CardHeader>
|
||||
<CardBody>Click anywhere on this card to trigger the press handler.</CardBody>
|
||||
<CardFooter>Click to interact</CardFooter>
|
||||
</Card>`;
|
||||
|
||||
export const interactiveLinkSnippet = `<Card
|
||||
style={{ width: '300px' }}
|
||||
href="https://backstage.io"
|
||||
label="Open Backstage documentation"
|
||||
>
|
||||
<CardHeader>Link Card</CardHeader>
|
||||
<CardBody>This card navigates to a URL when clicked.</CardBody>
|
||||
<CardFooter>Opens backstage.io</CardFooter>
|
||||
</Card>`;
|
||||
|
||||
export const interactiveWithNestedButtonsSnippet = `import { Button, Flex } from '@backstage/ui';
|
||||
|
||||
<Card
|
||||
style={{ width: '300px' }}
|
||||
onPress={() => console.log('Card pressed')}
|
||||
label="View plugin details"
|
||||
>
|
||||
<CardHeader>Card with Actions</CardHeader>
|
||||
<CardBody>
|
||||
Clicking the card background triggers the card press handler.
|
||||
The buttons below remain independently interactive.
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Flex gap="2">
|
||||
<Button size="small" variant="secondary" onPress={() => console.log('Primary')}>
|
||||
Primary
|
||||
</Button>
|
||||
<Button size="small" variant="tertiary" onPress={() => console.log('Secondary')}>
|
||||
Secondary
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardFooter>
|
||||
</Card>`;
|
||||
|
||||
export const withLongBodySnippet = `import { Text } from '@backstage/ui';
|
||||
|
||||
<Card style={{ width: '300px', height: '200px' }}>
|
||||
|
||||
@@ -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,16 @@ export interface CardBodyProps
|
||||
extends CardBodyOwnProps,
|
||||
React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
// @public (undocumented)
|
||||
export type CardButtonVariant = {
|
||||
onPress: NonNullable<ButtonProps_2['onPress']>;
|
||||
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<HTMLDivElement> {}
|
||||
|
||||
// @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<HTMLDivElement> {}
|
||||
export type CardOwnProps = Pick<
|
||||
CardBaseProps & (CardButtonVariant | CardLinkVariant | CardStaticVariant),
|
||||
| 'children'
|
||||
| 'className'
|
||||
| 'onPress'
|
||||
| 'href'
|
||||
| 'label'
|
||||
| 'target'
|
||||
| 'rel'
|
||||
| 'download'
|
||||
>;
|
||||
|
||||
// @public
|
||||
export type CardProps = CardBaseProps &
|
||||
Omit<React.HTMLAttributes<HTMLDivElement>, '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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,49 +27,64 @@ const meta = preview.meta({
|
||||
});
|
||||
|
||||
export const Default = meta.story({
|
||||
render: args => <Card {...args}>Hello world</Card>,
|
||||
});
|
||||
|
||||
export const DefaultWithHeader = meta.story({
|
||||
render: args => (
|
||||
<Card {...args}>
|
||||
<CardHeader>Header</CardHeader>
|
||||
<CardBody>Body</CardBody>
|
||||
<CardFooter>Footer</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
|
||||
export const CustomSize = Default.extend({
|
||||
args: {
|
||||
style: {
|
||||
width: '300px',
|
||||
height: '200px',
|
||||
},
|
||||
},
|
||||
const content = (
|
||||
<>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
export const LongBody = meta.story({
|
||||
render: () => (
|
||||
<Card style={{ width: '300px', height: '200px' }}>{content}</Card>
|
||||
),
|
||||
});
|
||||
|
||||
export const WithLongBody = meta.story({
|
||||
export const LongBodyHeader = meta.story({
|
||||
render: () => (
|
||||
<Card style={{ width: '300px', height: '200px' }}>
|
||||
<CardHeader>
|
||||
<Text>Header</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
</CardBody>
|
||||
<CardBody>{content}</CardBody>
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
|
||||
export const LongBodyHeaderFooter = meta.story({
|
||||
render: () => (
|
||||
<Card style={{ width: '300px', height: '200px' }}>
|
||||
<CardHeader>
|
||||
<Text>Header</Text>
|
||||
</CardHeader>
|
||||
<CardBody>{content}</CardBody>
|
||||
<CardFooter>
|
||||
<Text>Footer</Text>
|
||||
</CardFooter>
|
||||
@@ -77,7 +92,7 @@ export const WithLongBody = meta.story({
|
||||
),
|
||||
});
|
||||
|
||||
const ListRow = ({ children }: { children: React.ReactNode }) => {
|
||||
const ListRowComponent = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -89,7 +104,6 @@ const ListRow = ({ children }: { children: React.ReactNode }) => {
|
||||
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 = (
|
||||
<Flex direction="column" gap="1">
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const ListRow = meta.story({
|
||||
render: () => (
|
||||
<Card style={{ width: '300px', height: '200px' }}>
|
||||
<CardBody>{listRowContent}</CardBody>
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
|
||||
export const ListRowHeader = meta.story({
|
||||
render: () => (
|
||||
<Card style={{ width: '300px', height: '200px' }}>
|
||||
<CardHeader>
|
||||
<Text>Header</Text>
|
||||
</CardHeader>
|
||||
<CardBody>{listRowContent}</CardBody>
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
|
||||
export const ListRowFooter = meta.story({
|
||||
render: () => (
|
||||
<Card style={{ width: '300px', height: '200px' }}>
|
||||
<CardBody>{listRowContent}</CardBody>
|
||||
<CardFooter>
|
||||
<Text>Footer</Text>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
});
|
||||
|
||||
export const ListRowHeaderFooter = meta.story({
|
||||
render: () => (
|
||||
<Card style={{ width: '300px', height: '200px' }}>
|
||||
<CardHeader>
|
||||
<Text>Header</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<ListRow>Hello world</ListRow>
|
||||
<Flex direction="column" gap="1">
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
<ListRowComponent>Hello world</ListRowComponent>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Text>Footer</Text>
|
||||
@@ -231,6 +292,91 @@ 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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open Backstage documentation"
|
||||
label="Open Backstage documentation"
|
||||
>
|
||||
<CardHeader>Link Card</CardHeader>
|
||||
<CardBody>
|
||||
<Text>
|
||||
This card navigates to a URL when clicked. The entire card surface
|
||||
acts as a link.
|
||||
</Text>
|
||||
</CardBody>
|
||||
<CardFooter>Opens backstage.io</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">
|
||||
|
||||
@@ -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<HTMLDivElement, CardProps>((props, ref) => {
|
||||
CardDefinition,
|
||||
props,
|
||||
);
|
||||
const { classes, children } = ownProps;
|
||||
const { classes, children, onPress, href, label, target, rel, download } =
|
||||
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}
|
||||
target={target}
|
||||
rel={rel}
|
||||
download={download}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
{onPress && !href && (
|
||||
<RAButton
|
||||
className={classes.overlay}
|
||||
onPress={onPress}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -31,10 +31,17 @@ export const CardDefinition = defineComponent<CardOwnProps>()({
|
||||
styles,
|
||||
classNames: {
|
||||
root: 'bui-Card',
|
||||
overlay: 'bui-CardOverlay',
|
||||
},
|
||||
propDefs: {
|
||||
children: {},
|
||||
className: {},
|
||||
onPress: {},
|
||||
href: {},
|
||||
label: {},
|
||||
target: {},
|
||||
rel: {},
|
||||
download: {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<RAButtonProps['onPress']>;
|
||||
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<HTMLDivElement> {}
|
||||
export type CardProps = CardBaseProps &
|
||||
Omit<React.HTMLAttributes<HTMLDivElement>, '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 = {
|
||||
|
||||
Reference in New Issue
Block a user