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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user