Fix for scrollable body section

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-03-04 16:59:25 +00:00
parent db927510dc
commit 90b1cfc8a0
3 changed files with 104 additions and 10 deletions
@@ -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;
}
@@ -231,6 +231,39 @@ 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
+33 -3
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { forwardRef } from 'react';
import { forwardRef, useCallback } from 'react';
import { Button as RAButton, Link as RALink } from 'react-aria-components';
import { useDefinition } from '../../hooks/useDefinition';
import {
@@ -46,6 +46,29 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((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<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"
@@ -53,16 +76,23 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
className={classes.root}
data-interactive={isInteractive || undefined}
{...dataAttributes}
{...restProps}
{...restPropsWithoutClick}
onClick={handleContainerClick}
>
{href && (
<RALink className={classes.overlay} href={href} aria-label={label} />
<RALink
className={classes.overlay}
href={href}
aria-label={label}
onClick={e => e.stopPropagation()}
/>
)}
{onPress && !href && (
<RAButton
className={classes.overlay}
onPress={onPress}
aria-label={label}
onClick={e => e.stopPropagation()}
/>
)}
{children}