Files
xrpl-dev-portal/shared/components/CardImage/CardImage.tsx
Calvin 5021556c8e BDS component polish: link contrast, static cards, uniform CTAs, stacked hero (#3851)
* BDS component polish: link visibility, static cards, uniform CTAs

Component-level fixes and refinements across the 2026 brand components.

Accessibility / contrast
- StandardCard: pin description link colors. Every card variant has a light
  background in both themes, so theme-level link colors were wrong inside it —
  dark mode painted plain anchors white and BdsLink lilac-300, both of which
  wash out on a pale card. Links (including :visited) now use the card's own
  text color with an underline carrying the affordance; hover uses lilac-500,
  which clears 4.5:1 on all four card backgrounds.
- Breadcrumbs: point the open dropdown menu at the breadcrumb color tokens so
  the trail, trigger, and menu read as one color in both themes, and give dark
  mode gray-6 (7.41:1, matching light mode's 7.23:1).

Interaction
- CardOffgrid: drop every hover affordance when a card has neither href nor
  onClick. Static cards no longer get a pointer cursor, the color-wipe overlay,
  or a pressed state — the tokenization and trading carousels pass link-less
  cards and were advertising a click target that did not exist.
- CarouselFeatured: add a 'fade' transition alongside the default 'slide', for
  decks whose slides share a heading. Inactive slides are now inert, keeping
  focus and pointer events out of them in both styles.
- Add a bds-reduced-motion mixin so components can collapse motion to an
  instant state change.

Design consistency
- ButtonGroup: add forceVariant and forceNoPadding overrides, letting a section
  opt out of the count-based variant defaults and render one uniform treatment.
  Existing consumers are unaffected.
- FeatureTwoColumn: render every link as a tertiary button regardless of count,
  flush with the title and description.
- CardImage: render an all-bullet subtitle as a real <ul> so wrapped text hangs
  under the first character and screen readers announce it as a list.
- docs: give the node-installation card descriptions a paragraph break before
  their "Learn More" link.

Dependencies
- Bump @codemirror/state and view, add lang-javascript, lang-json, and lint,
  with overrides pinning state/view to one copy.

Docs updated alongside each component. Built CSS regenerated with the
production script to match the committed artifact's minified format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ButtonGroup: don't strip padding from forced filled variants

`noPadding` was derived as `forceNoPadding || isMultiButton`, which was safe
while the 3+ block layout was always tertiary. `forceVariant` also accepts
`primary` and `secondary`, so a 3-button group forcing a filled variant had
`padding: 0 !important` (Button.scss) applied with no way to opt out.

Tie the implied no-padding to the resolved variant being tertiary. Behavior is
unchanged for every caller that doesn't pass `forceVariant`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stacked hero variant, fade-by-default carousel, component docs

Layout
- HeaderHeroPrimaryMedia: add a `stacked` prop that puts the headline, subtitle,
  and buttons in one column (full width at base, 7/8 at md, 9/12 at lg) instead
  of the default headline-left / CTA-right split. The headline and CTA block
  bottom-align against each other in the two-column layout, so the stacked
  variant undoes that. Both arrangements now share the same headline and CTA
  elements, so they can't drift apart. Used on the docs landing hero.

Motion
- CarouselFeatured: make `fade` the default transition. Slides that share a
  heading are the common case, and a horizontal wipe drags the identical heading
  across the screen only to set it back down. `slide` is now opt-in for decks
  whose panels are genuinely distinct. Nudge the crossfade to 260ms in / 200ms
  out. The home page carousel opts into fade explicitly; developer-funding now
  inherits it.

Fixes
- CardTextIconCard: pass `headingAs` through to `cardContent`, which was
  accepting the prop but never receiving it.

Docs
- Expand the Button, CardImage, CardTextIcon, PageGrid, CalloutMediaBanner,
  LogoSquareGrid, and HeaderHeroPrimaryMedia references.

Built CSS regenerated with the production script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:49:10 -07:00

241 lines
6.2 KiB
TypeScript

import React, { useState, useCallback } from 'react';
import clsx from 'clsx';
import { Button } from '../Button';
export interface CardImageProps {
/** Image source URL */
image: string;
/** Alt text for the image */
imageAlt: string;
/** Card title (1 line only) */
title: string;
/** Card subtitle (max 3 lines). `\n` renders as a line break; an all-bullet subtitle renders as a list. */
subtitle: string;
/** Button label text */
buttonLabel: string;
/** Link destination (renders card as clickable link) */
href?: string;
/** Click handler for the button */
onClick?: () => void;
/** Disabled state */
disabled?: boolean;
/** Optional className for custom styling */
className?: string;
/** When true, image fills entire container with object-fit: cover (no visible background) */
fullBleed?: boolean;
/** Custom background color for image container (defaults to gray-100) */
backgroundColor?: string;
}
const CtaArrowIcon: React.FC = () => (
<svg
className="bds-btn__icon"
width="15"
height="14"
viewBox="0 0 15 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<line
className="bds-btn__icon-line"
x1="0"
y1="7"
x2="14"
y2="7"
stroke="currentColor"
strokeWidth="1.3"
strokeMiterlimit="10"
/>
<path
className="bds-btn__icon-chevron"
d="M8.16755 1.16743L14.0005 7.00038L8.16755 12.8333"
stroke="currentColor"
strokeWidth="1.3"
strokeMiterlimit="10"
fill="none"
/>
</svg>
);
// Matches a leading bullet marker on a subtitle line, e.g. "\u2022 ", "- ", "* ".
const BULLET_MARKER = /^\s*[\u2022\u2023\u25E6\u2043\-*]\s+/;
/**
* Renders the subtitle.
*
* When every line of the subtitle is a bullet (`\n`-separated), it renders as a
* real `<ul>` so wrapped text hangs-indents under the first character instead of
* running back to the left edge — and so screen readers announce it as a list.
* Any other subtitle renders as a paragraph, where `white-space: pre-line` still
* turns `\n` into a plain line break.
*/
const renderSubtitle = (subtitle: string): React.ReactNode => {
const lines = subtitle.split('\n').filter((line) => line.trim() !== '');
const isBulletList = lines.length > 0 && lines.every((line) => BULLET_MARKER.test(line));
if (!isBulletList) {
return <p className="bds-card-image__subtitle body-l">{subtitle}</p>;
}
return (
<ul className="bds-card-image__subtitle bds-card-image__subtitle--list body-l">
{lines.map((line, index) => (
<li key={index} className="bds-card-image__subtitle-item">
{line.replace(BULLET_MARKER, '')}
</li>
))}
</ul>
);
};
/**
* BDS CardImage Component
*
* A responsive card component displaying an image, title, subtitle, and CTA button.
* Features three responsive size variants (LG/MD/SM) that adapt to viewport width.
*
* Key behaviors:
* - Hovering the card triggers the button's hover animation
* - Card can link to a URL or trigger a click handler
* - Supports disabled state
*
* @example
* // Basic card with link
* <CardImage
* image="/images/docs-hero.png"
* imageAlt="Documentation illustration"
* title="Documentation"
* subtitle="Access everything you need to get started working with the XRPL."
* buttonLabel="Get Started"
* href="/docs"
* />
*
* @example
* // Card with click handler
* <CardImage
* image="/images/feature.png"
* imageAlt="Feature illustration"
* title="New Feature"
* subtitle="Learn about our latest feature."
* buttonLabel="Learn More"
* onClick={() => console.log('clicked')}
* />
*/
export const CardImage: React.FC<CardImageProps> = ({
image,
imageAlt,
title,
subtitle,
buttonLabel,
href,
onClick,
disabled = false,
className = '',
fullBleed = false,
backgroundColor,
}) => {
// Track hover state for button animation
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = useCallback(() => {
if (!disabled) {
setIsHovered(true);
}
}, [disabled]);
const handleMouseLeave = useCallback(() => {
if (!disabled) {
setIsHovered(false);
}
}, [disabled]);
// Build class names using BEM with bds namespace
const classNames = clsx(
'bds-card-image',
disabled && 'bds-card-image--disabled',
isHovered && 'bds-card-image--hovered',
fullBleed && 'bds-card-image--full-bleed',
className
);
// Handle button click
const handleButtonClick = useCallback(() => {
onClick?.();
}, [onClick]);
// Build inline style for image container background color
const imageContainerStyle = backgroundColor
? { '--bds-card-image-bg': backgroundColor } as React.CSSProperties
: undefined;
const cta = href && !disabled ? (
<span className="bds-btn bds-btn--primary bds-btn--green">
<span className="bds-btn__label">{buttonLabel}</span>
<CtaArrowIcon />
</span>
) : (
<Button
variant="primary"
color="green"
disabled={disabled}
onClick={handleButtonClick}
>
{buttonLabel}
</Button>
);
// Common content structure
const content = (
<>
{/* Image container with customizable background */}
<div className="bds-card-image__image-container" style={imageContainerStyle}>
<img
src={image}
alt={imageAlt}
className="bds-card-image__image"
loading="lazy"
decoding="async"
/>
</div>
{/* Content area: title, subtitle, and button */}
<div className="bds-card-image__content">
<div className="bds-card-image__text">
<h3 className="bds-card-image__title sh-md-l">{title}</h3>
{renderSubtitle(subtitle)}
</div>
{cta}
</div>
</>
);
if (href && !disabled) {
return (
<a
href={href}
className={classNames}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick ? () => onClick() : undefined}
>
{content}
</a>
);
}
return (
<div
className={classNames}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
aria-disabled={disabled}
>
{content}
</div>
);
};
export default CardImage;