fix(ui): allow CardBody scrolling in interactive cards (#33151)

* refactor(ui): rename CardOverlay to CardTrigger

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* refactor(ui): replace Card overlay with visually-hidden trigger and focus ring

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): implement hybrid click delegation for interactive cards

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): add story for interactive scrollable card

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: add changeset for interactive card scroll fix

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(ui): fix bottom scroll shadow showing when CardBody has no overflow

The reversed scroll-driven animation used `both` fill mode, causing the
backwards fill to show opacity: 1 when no scroll timeline was active.
Replace with two stacked animations using `forwards` fill only.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): update InteractiveScrollable story to use onPress

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* style: format Card.module.css

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(ui): guard against Text node target in Card click handler

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): render Card as article element for better semantics

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore(ui): update API report

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(ui): prevent synthetic click from double-bubbling and exclude onClick from Card types

Signed-off-by: Johan Persson <johanopersson@gmail.com>

---------

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-05 15:57:00 +01:00
committed by GitHub
parent 4c32f367e4
commit 430d5ed323
7 changed files with 106 additions and 81 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Fixed interactive cards so that CardBody can scroll when the card has a constrained height. Previously, the overlay element blocked scroll events.
**Affected components:** Card
+2 -2
View File
@@ -619,7 +619,7 @@ export const CardDefinition: {
};
readonly classNames: {
readonly root: 'bui-Card';
readonly overlay: 'bui-CardOverlay';
readonly trigger: 'bui-CardTrigger';
};
readonly propDefs: {
readonly children: {};
@@ -718,7 +718,7 @@ export type CardOwnProps = Pick<
// @public
export type CardProps = CardBaseProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onPress'> &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick'> &
(CardButtonVariant | CardLinkVariant | CardStaticVariant);
// @public (undocumented)
+20 -69
View File
@@ -47,11 +47,10 @@
/*
* Cursor and hover tint are applied at the card level so they cover the
* entire surface. The overlay inherits the cursor via cursor: inherit.
* entire surface.
*/
.bui-Card[data-interactive] {
cursor: pointer;
overflow: hidden;
&::after {
content: '';
@@ -70,72 +69,22 @@
}
}
.bui-CardOverlay {
.bui-Card[data-interactive]:has(.bui-CardTrigger:focus-visible) {
outline: 2px solid var(--bui-ring);
outline-offset: -2px;
}
.bui-CardTrigger {
position: absolute;
inset: 0;
z-index: 1;
background: transparent;
border-radius: inherit;
border: none;
width: 1px;
height: 1px;
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;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(100%);
white-space: nowrap;
border: 0;
}
.bui-CardHeader {
@@ -199,9 +148,11 @@
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%;
opacity: 0;
animation: bui-card-body-shadow linear forwards,
bui-card-body-shadow linear forwards reverse;
animation-timeline: scroll(), scroll();
animation-range: 0px 1px, calc(100% - 2.5rem) 100%;
}
}
@@ -377,6 +377,26 @@ export const InteractiveWithNestedButtons = meta.story({
),
});
export const InteractiveScrollable = meta.story({
render: () => (
<Card
style={{ width: '300px', height: '200px' }}
onPress={() => alert('Card pressed')}
label="View card details"
>
<CardHeader>
<Text weight="bold">Scrollable Interactive Card</Text>
</CardHeader>
<CardBody>{content}</CardBody>
<CardFooter>
<Text variant="body-small" color="secondary">
Card body scrolls while card remains clickable
</Text>
</CardFooter>
</Card>
),
});
export const CustomCardWithBox = meta.story({
render: () => (
<Flex direction="column" gap="4">
+55 -8
View File
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { forwardRef } from 'react';
import { Button as RAButton, Link as RALink } from 'react-aria-components';
import { forwardRef, useCallback, useRef } from 'react';
import { Button as RAButton } from 'react-aria-components';
import { useDefinition } from '../../hooks/useDefinition';
import {
CardDefinition,
@@ -30,6 +30,10 @@ import type {
CardFooterProps,
} from './types';
import { Box } from '../Box/Box';
import { Link } from '../Link';
const INTERACTIVE_ELEMENT_SELECTOR =
'a[href],button,input,select,textarea,[role="button"],[role="link"],[tabindex]:not([tabindex="-1"])';
/**
* Card component.
@@ -41,24 +45,66 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
CardDefinition,
props,
);
const { classes, children, onPress, href, label, target, rel, download } =
ownProps;
const {
classes,
children,
onPress,
href,
label,
target: linkTarget,
rel,
download,
} = ownProps;
const isInteractive = !!(onPress || href);
const triggerRef = useRef<HTMLAnchorElement | HTMLButtonElement>(null);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isInteractive || !triggerRef.current) return;
// Don't delegate if the click target is the trigger itself
if (triggerRef.current.contains(e.target as Node)) return;
// Don't delegate if the user clicked a nested interactive element
const targetNode = e.target as Node | null;
const targetElement =
targetNode instanceof Element ? targetNode : targetNode?.parentElement;
if (targetElement?.closest(INTERACTIVE_ELEMENT_SELECTOR)) return;
// Don't delegate if the user is selecting text
if (window.getSelection()?.toString()) return;
triggerRef.current.dispatchEvent(
new MouseEvent('click', {
bubbles: false,
cancelable: true,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
}),
);
},
[isInteractive],
);
return (
<Box
as="article"
bg="neutral"
ref={ref}
className={classes.root}
data-interactive={isInteractive || undefined}
{...dataAttributes}
{...restProps}
onClick={isInteractive ? handleClick : undefined}
>
{href && (
<RALink
className={classes.overlay}
<Link
ref={triggerRef as React.Ref<HTMLAnchorElement>}
className={classes.trigger}
href={href}
target={target}
target={linkTarget}
rel={rel}
download={download}
aria-label={label}
@@ -66,7 +112,8 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
)}
{onPress && !href && (
<RAButton
className={classes.overlay}
ref={triggerRef as React.Ref<HTMLButtonElement>}
className={classes.trigger}
onPress={onPress}
aria-label={label}
/>
@@ -31,7 +31,7 @@ export const CardDefinition = defineComponent<CardOwnProps>()({
styles,
classNames: {
root: 'bui-Card',
overlay: 'bui-CardOverlay',
trigger: 'bui-CardTrigger',
},
propDefs: {
children: {},
+1 -1
View File
@@ -63,7 +63,7 @@ export type CardStaticVariant = {
* @public
*/
export type CardProps = CardBaseProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onPress'> &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick'> &
(CardButtonVariant | CardLinkVariant | CardStaticVariant);
/**