diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 503ec19414..31617a36bb 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -623,6 +623,9 @@ export const CardDefinition: { readonly onPress: {}; readonly href: {}; readonly label: {}; + readonly target: {}; + readonly rel: {}; + readonly download: {}; }; }; @@ -690,7 +693,8 @@ export interface CardHeaderProps export type CardLinkVariant = { href: string; onPress?: never; - label?: string; + label: string; + target?: string; }; // @public @@ -700,6 +704,9 @@ export type CardOwnProps = { onPress?: () => void; href?: string; label?: string; + target?: string; + rel?: string; + download?: boolean | string; }; // @public diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index 075a8dc556..978731344d 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -20,24 +20,39 @@ .bui-Card { display: flex; flex-direction: column; - gap: var(--bui-space-3); border-radius: var(--bui-radius-3); - padding-block: var(--bui-space-3); color: var(--bui-fg-primary); - overflow: hidden; + overflow: auto; min-height: 0; width: 100%; position: relative; + padding: var(--bui-space-3); + } + + .bui-Card[data-bg='neutral-1'] { + --bui-card-bg: var(--bui-bg-neutral-1); + } + + .bui-Card[data-bg='neutral-2'] { + --bui-card-bg: var(--bui-bg-neutral-2); + } + + .bui-Card[data-bg='neutral-3'] { + --bui-card-bg: var(--bui-bg-neutral-3); + } + + .bui-Card:has(.bui-CardHeader, .bui-CardBody, .bui-CardFooter) { + padding: 0; } /* * Cursor and hover tint are applied at the card level so they cover the - * entire surface — including CardBody which sits above the overlay. + * entire surface. The overlay inherits the cursor via cursor: inherit. */ .bui-Card[data-interactive] { cursor: pointer; - &:hover::after { + &::after { content: ''; position: absolute; inset: 0; @@ -45,6 +60,12 @@ border-radius: inherit; pointer-events: none; z-index: 3; + opacity: 0; + transition: opacity 200ms ease-in-out; + } + + &:hover::after { + opacity: 1; } } @@ -59,6 +80,7 @@ appearance: none; display: block; width: 100%; + cursor: inherit; &:focus-visible { outline: 2px solid var(--bui-ring); @@ -80,12 +102,12 @@ } /* - * 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. + * 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( @@ -96,24 +118,94 @@ input, select, textarea, - .bui-CardBody + .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; + } + + .bui-CardHeader { + padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-3); + } + .bui-CardBody { flex: 1; min-height: 0; overflow: auto; padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-3); } - .bui-CardHeader { - padding-inline: var(--bui-space-3); + @keyframes bui-card-body-shadow { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .bui-Card:has(.bui-CardHeader) .bui-CardBody { + padding-block-start: 0; + + &::before { + content: ''; + position: sticky; + top: 0; + display: block; + height: 1.5rem; + margin-bottom: -1.5rem; + background: linear-gradient( + to bottom, + var(--bui-card-bg), + rgb(from var(--bui-card-bg) r g b / 0) + ); + pointer-events: none; + opacity: 0; + animation: bui-card-body-shadow linear both; + animation-timeline: scroll(); + animation-range: 0px 2.5rem; + } + } + + .bui-Card:has(.bui-CardFooter) .bui-CardBody { + padding-block-end: 0; + + &::after { + content: ''; + position: sticky; + bottom: 0; + display: block; + height: 1.5rem; + margin-top: -1.5rem; + background: linear-gradient( + to top, + var(--bui-card-bg), + 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%; + } } .bui-CardFooter { padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-3); } } diff --git a/packages/ui/src/components/Card/Card.stories.tsx b/packages/ui/src/components/Card/Card.stories.tsx index e51a2c748b..117859a1bb 100644 --- a/packages/ui/src/components/Card/Card.stories.tsx +++ b/packages/ui/src/components/Card/Card.stories.tsx @@ -27,49 +27,64 @@ const meta = preview.meta({ }); export const Default = meta.story({ + render: args => Hello world, +}); + +export const DefaultWithHeader = meta.story({ render: args => ( Header Body - Footer ), }); -export const CustomSize = Default.extend({ - args: { - style: { - width: '300px', - height: '200px', - }, - }, +const content = ( + <> + + 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 LongBody = meta.story({ + render: () => ( + {content} + ), }); -export const WithLongBody = meta.story({ +export const LongBodyHeader = meta.story({ render: () => ( Header - - - 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. - - + {content} + + ), +}); + +export const LongBodyHeaderFooter = meta.story({ + render: () => ( + + + Header + + {content} Footer @@ -77,7 +92,7 @@ export const WithLongBody = meta.story({ ), }); -const ListRow = ({ children }: { children: React.ReactNode }) => { +const ListRowComponent = ({ children }: { children: React.ReactNode }) => { return (
{ paddingInline: 'var(--bui-space-3)', borderRadius: 'var(--bui-radius-2)', fontSize: 'var(--bui-font-size-3)', - marginBottom: 'var(--bui-space-1)', }} > {children} @@ -97,29 +111,76 @@ const ListRow = ({ children }: { children: React.ReactNode }) => { ); }; -export const WithListRow = meta.story({ +const listRowContent = ( + + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + +); + +export const ListRow = meta.story({ + render: () => ( + + {listRowContent} + + ), +}); + +export const ListRowHeader = meta.story({ + render: () => ( + + + Header + + {listRowContent} + + ), +}); + +export const ListRowFooter = meta.story({ + render: () => ( + + {listRowContent} + + Footer + + + ), +}); + +export const ListRowHeaderFooter = meta.story({ render: () => ( Header - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world - Hello world + + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Hello world + Footer @@ -231,39 +292,6 @@ 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: () => ( - - Link Card - + Link Card This card navigates to a URL when clicked. The entire card surface acts as a link. - - - Opens backstage.io - - + Opens backstage.io ), }); diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index 2a3e0f5124..b948e19df9 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { forwardRef, useCallback } from 'react'; +import { forwardRef } from 'react'; import { Button as RAButton, Link as RALink } from 'react-aria-components'; import { useDefinition } from '../../hooks/useDefinition'; import { @@ -43,32 +43,10 @@ export const Card = forwardRef((props, ref) => { props as CardOwnProps & Omit, 'onPress'>, ); - const { classes, children, onPress, href, label } = ownProps; + const { classes, children, onPress, href, label, target, rel, download } = + 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} - {...restPropsWithoutClick} - onClick={handleContainerClick} + {...restProps} > {href && ( e.stopPropagation()} /> )} {onPress && !href && ( @@ -92,7 +71,6 @@ export const Card = forwardRef((props, ref) => { className={classes.overlay} onPress={onPress} aria-label={label} - onClick={e => e.stopPropagation()} /> )} {children} diff --git a/packages/ui/src/components/Card/definition.ts b/packages/ui/src/components/Card/definition.ts index 814b42aa9d..3408828a4d 100644 --- a/packages/ui/src/components/Card/definition.ts +++ b/packages/ui/src/components/Card/definition.ts @@ -39,6 +39,9 @@ export const CardDefinition = defineComponent()({ onPress: {}, href: {}, label: {}, + target: {}, + rel: {}, + download: {}, }, }); diff --git a/packages/ui/src/components/Card/types.ts b/packages/ui/src/components/Card/types.ts index a00e0b797f..f70f4b7c3b 100644 --- a/packages/ui/src/components/Card/types.ts +++ b/packages/ui/src/components/Card/types.ts @@ -26,6 +26,9 @@ export type CardOwnProps = { onPress?: () => void; href?: string; label?: string; + target?: string; + rel?: string; + download?: boolean | string; }; /** @public */ @@ -46,7 +49,9 @@ export type CardLinkVariant = { href: string; onPress?: never; /** Accessible label announced by screen readers for the interactive card. */ - label?: string; + label: string; + /** Specifies where to open the linked URL (e.g. `_blank` for a new tab). */ + target?: string; }; /** @public */