From 90b1cfc8a0f88723fbb85a4478bdaf5e9f39f479 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 4 Mar 2026 16:59:25 +0000 Subject: [PATCH] Fix for scrollable body section Signed-off-by: Charles de Dreuille --- .../ui/src/components/Card/Card.module.css | 45 ++++++++++++++++--- .../ui/src/components/Card/Card.stories.tsx | 33 ++++++++++++++ packages/ui/src/components/Card/Card.tsx | 36 +++++++++++++-- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index 5e59d1e930..075a8dc556 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -30,12 +30,29 @@ position: relative; } + /* + * Cursor and hover tint are applied at the card level so they cover the + * entire surface — including CardBody which sits above the overlay. + */ + .bui-Card[data-interactive] { + cursor: pointer; + + &:hover::after { + content: ''; + position: absolute; + inset: 0; + background: color-mix(in srgb, currentColor 5%, transparent); + border-radius: inherit; + pointer-events: none; + z-index: 3; + } + } + .bui-CardOverlay { position: absolute; inset: 0; z-index: 1; background: transparent; - cursor: pointer; border-radius: inherit; border: none; padding: 0; @@ -48,7 +65,10 @@ outline-offset: -2px; } - &:hover::after, + /* + * Keep focus tint for keyboard navigation (hover tint has moved to the + * card container above). + */ &[data-focused]::after { content: ''; position: absolute; @@ -60,13 +80,24 @@ } /* - * Interactive elements inside the card must sit above the overlay so they - * remain independently clickable. The overlay handles the rest of the surface. + * 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. */ .bui-Card[data-interactive] - :is(button, a[href], [role='button'], [role='link'], input, select, textarea):not( - .bui-CardOverlay - ) { + :is( + button, + a[href], + [role='button'], + [role='link'], + input, + select, + textarea, + .bui-CardBody + ):not(.bui-CardOverlay) { position: relative; z-index: 2; } diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index 41b43b89a4..e51a2c748b 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -231,6 +231,39 @@ export const BgOnProviders = meta.story({ ), }); +export const InteractiveWithScrollableBody = meta.story({ + render: () => ( + alert('Card pressed')} + label="View details" + > + + Scrollable Interactive Card + + + + 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. + + + 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. + + + 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. + + + + ), +}); + export const Interactive = meta.story({ render: () => ( ((props, ref) => { const { classes, children, onPress, href, label } = 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; + + const handleContainerClick = useCallback( + (e: React.MouseEvent) => { + 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 ( ((props, ref) => { className={classes.root} data-interactive={isInteractive || undefined} {...dataAttributes} - {...restProps} + {...restPropsWithoutClick} + onClick={handleContainerClick} > {href && ( - + e.stopPropagation()} + /> )} {onPress && !href && ( e.stopPropagation()} /> )} {children}