From 430d5ed32379d11b5f5441fb04267163d4a1fe84 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Thu, 5 Mar 2026 15:57:00 +0100 Subject: [PATCH] fix(ui): allow CardBody scrolling in interactive cards (#33151) * refactor(ui): rename CardOverlay to CardTrigger Signed-off-by: Johan Persson * refactor(ui): replace Card overlay with visually-hidden trigger and focus ring Signed-off-by: Johan Persson * feat(ui): implement hybrid click delegation for interactive cards Signed-off-by: Johan Persson * docs(ui): add story for interactive scrollable card Signed-off-by: Johan Persson * chore: add changeset for interactive card scroll fix Signed-off-by: Johan Persson * 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 * docs(ui): update InteractiveScrollable story to use onPress Signed-off-by: Johan Persson * style: format Card.module.css Signed-off-by: Johan Persson * fix(ui): guard against Text node target in Card click handler Signed-off-by: Johan Persson * feat(ui): render Card as article element for better semantics Signed-off-by: Johan Persson * chore(ui): update API report Signed-off-by: Johan Persson * fix(ui): prevent synthetic click from double-bubbling and exclude onClick from Card types Signed-off-by: Johan Persson --------- Signed-off-by: Johan Persson --- .changeset/card-hybrid-click.md | 7 ++ packages/ui/report.api.md | 4 +- .../ui/src/components/Card/Card.module.css | 89 +++++-------------- .../ui/src/components/Card/Card.stories.tsx | 20 +++++ packages/ui/src/components/Card/Card.tsx | 63 +++++++++++-- packages/ui/src/components/Card/definition.ts | 2 +- packages/ui/src/components/Card/types.ts | 2 +- 7 files changed, 106 insertions(+), 81 deletions(-) create mode 100644 .changeset/card-hybrid-click.md diff --git a/.changeset/card-hybrid-click.md b/.changeset/card-hybrid-click.md new file mode 100644 index 0000000000..48343c72a7 --- /dev/null +++ b/.changeset/card-hybrid-click.md @@ -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 diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index afda04ea5a..a4777d408d 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -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, 'onPress'> & + Omit, 'onClick'> & (CardButtonVariant | CardLinkVariant | CardStaticVariant); // @public (undocumented) diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index f0aaa6831f..231c6c55e0 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -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%; } } diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index 117859a1bb..bb756d312b 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -377,6 +377,26 @@ export const InteractiveWithNestedButtons = meta.story({ ), }); +export const InteractiveScrollable = meta.story({ + render: () => ( + alert('Card pressed')} + label="View card details" + > + + Scrollable Interactive Card + + {content} + + + Card body scrolls while card remains clickable + + + + ), +}); + 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 2527a3a46b..70a2438a49 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -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((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(null); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + 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 ( {href && ( - } + className={classes.trigger} href={href} - target={target} + target={linkTarget} rel={rel} download={download} aria-label={label} @@ -66,7 +112,8 @@ export const Card = forwardRef((props, ref) => { )} {onPress && !href && ( } + className={classes.trigger} onPress={onPress} aria-label={label} /> diff --git a/packages/ui/src/components/Card/definition.ts b/packages/ui/src/components/Card/definition.ts index 3408828a4d..5d69005b4c 100644 --- a/packages/ui/src/components/Card/definition.ts +++ b/packages/ui/src/components/Card/definition.ts @@ -31,7 +31,7 @@ export const CardDefinition = defineComponent()({ styles, classNames: { root: 'bui-Card', - overlay: 'bui-CardOverlay', + trigger: 'bui-CardTrigger', }, propDefs: { children: {}, diff --git a/packages/ui/src/components/Card/types.ts b/packages/ui/src/components/Card/types.ts index f1e91055e9..5809384c16 100644 --- a/packages/ui/src/components/Card/types.ts +++ b/packages/ui/src/components/Card/types.ts @@ -63,7 +63,7 @@ export type CardStaticVariant = { * @public */ export type CardProps = CardBaseProps & - Omit, 'onPress'> & + Omit, 'onClick'> & (CardButtonVariant | CardLinkVariant | CardStaticVariant); /**