Card styles updates

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-03-04 19:40:25 +00:00
parent 90b1cfc8a0
commit 0c772a3223
6 changed files with 243 additions and 133 deletions
+8 -1
View File
@@ -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
+106 -14
View File
@@ -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);
}
}
+113 -88
View File
@@ -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,39 +292,6 @@ export const BgOnProviders = meta.story({
),
});
export const InteractiveWithScrollableBody = meta.story({
render: () => (
<Card
style={{ width: '300px', height: '220px' }}
onPress={() => alert('Card pressed')}
label="View details"
>
<CardHeader>
<Text weight="bold">Scrollable Interactive Card</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>
</Card>
),
});
export const Interactive = meta.story({
render: () => (
<Card
@@ -294,22 +322,19 @@ export const InteractiveAsLink = meta.story({
<Card
style={{ width: '300px' }}
href="https://backstage.io"
target="_blank"
rel="noopener noreferrer"
title="Open Backstage documentation"
label="Open Backstage documentation"
>
<CardHeader>
<Text weight="bold">Link Card</Text>
</CardHeader>
<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>
<Text variant="body-small" color="secondary">
Opens backstage.io
</Text>
</CardFooter>
<CardFooter>Opens backstage.io</CardFooter>
</Card>
),
});
+7 -29
View File
@@ -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<HTMLDivElement, CardProps>((props, ref) => {
props as CardOwnProps &
Omit<React.HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement>;
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
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 (
<Box
bg="neutral"
@@ -76,15 +54,16 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
className={classes.root}
data-interactive={isInteractive || undefined}
{...dataAttributes}
{...restPropsWithoutClick}
onClick={handleContainerClick}
{...restProps}
>
{href && (
<RALink
className={classes.overlay}
href={href}
target={target}
rel={rel}
download={download}
aria-label={label}
onClick={e => e.stopPropagation()}
/>
)}
{onPress && !href && (
@@ -92,7 +71,6 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
className={classes.overlay}
onPress={onPress}
aria-label={label}
onClick={e => e.stopPropagation()}
/>
)}
{children}
@@ -39,6 +39,9 @@ export const CardDefinition = defineComponent<CardOwnProps>()({
onPress: {},
href: {},
label: {},
target: {},
rel: {},
download: {},
},
});
+6 -1
View File
@@ -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 */