A card with an image on top and a title, subtitle and meta row below, for artwork lists, artist tiles and booking previews. It composes `atoms.card` with no padding, so fill, border and corners are Card's.
registry/behivetech/molecules/image-card · depends on @behivetech/atoms.card, @behivetech/icons.base, @behivetech/hooks.use-image-status, @behivetech/icons.image
image-card-media.tsx
1import { isValidElement, type ReactNode } from "react";2import { getClassName } from "@behivetech/get-class-name";3import { ImageIcon } from "@behivetech/icons.image";4import { useImageStatus } from "@behivetech/hooks.use-image-status";5import styles from "./image-card-media.module.scss";67/** The media box ratios the design allows, written as CSS ratios. */8export type ImageCardAspectRatio = "4/5" | "1/1" | "3/2";910export interface ImageCardMediaProps {11 /** The image, or any node that holds one; see `ImageCardProps.media` */12 media: ReactNode;13 /** Label for the failed-image fallback, read before the image's alt text */14 unavailableLabel: string;15 /** Aspect ratio of the box, held before the image loads */16 aspectRatio: ImageCardAspectRatio;17}1819/** Modifier per ratio; a lookup, so a new ratio is one key here and one rule in the stylesheet. */20const ASPECT_MODIFIERS: Record<ImageCardAspectRatio, string> = {21 "4/5": "aspect-4-5",22 "1/1": "aspect-1-1",23 "3/2": "aspect-3-2",24};2526/**27 * Names the image inside `media` so a changed image clears an earlier failure.28 * It reads `src` from the root element only, because the image is replaced by29 * the fallback once it fails and can no longer be read from the DOM.30 */31const getMediaKey = (media: ReactNode): string => {32 let key = "";33 if (isValidElement<{ src?: string | { src?: string } }>(media)) {34 const { src } = media.props;35 key = (typeof src === "string" ? src : src?.src) ?? "";36 }37 return key;38};3940/**41 * The card's media box: holds its aspect ratio before the image paints and42 * swaps a failed image for an image icon at the same ratio.43 */44export const ImageCardMedia = ({45 media,46 unavailableLabel,47 aspectRatio,48}: ImageCardMediaProps) => {49 const { ref, status, alt, handlers } = useImageStatus(getMediaKey(media));50 const [rootClass, getChildClass] = getClassName({51 rootClass: "BHT__ImageCardMedia",52 modifiers: { [ASPECT_MODIFIERS[aspectRatio]]: true },53 styles,54 });5556 return (57 <div ref={ref} className={rootClass} {...handlers}>58 {status === "error" ? (59 <span60 className={getChildClass("fallback")}61 role="img"62 aria-label={alt ? `${unavailableLabel}: ${alt}` : unavailableLabel}63 >64 <ImageIcon size="xl" aria-hidden="true" />65 </span>66 ) : (67 media68 )}69 </div>70 );71};
image-card.tsx
1import { type ElementType, type HTMLAttributes, type ReactNode } from "react";2import { getClassName } from "@behivetech/get-class-name";3import { Card, type CardHeadingLevel } from "@behivetech/atoms.card";4import styles from "./image-card.module.scss";5import {6 ImageCardMedia,7 type ImageCardAspectRatio,8} from "./image-card-media.js";910export type { ImageCardAspectRatio };1112export interface ImageCardProps extends Omit<13 HTMLAttributes<HTMLDivElement>,14 "title" | "onSelect"15> {16 /**17 * The image, or any node that holds one (`next/image` with `fill` works).18 * Give the image its descriptive `alt`. If an `<img>` inside fails to load,19 * including before hydration, the media box swaps it for an image icon at20 * the same aspect ratio, labelled with `unavailableLabel` and the alt text.21 * A new `src` on the image clears the failure, but only when `media` is the22 * image element itself (an `<img>` or `next/image`). If `media` is a wrapper23 * or a `<picture>`, give the card a `key` that changes with the image.24 */25 media: ReactNode;26 /** Card title, clamped at two lines. It names the link when `href` is set. */27 title: ReactNode;28 /** Optional line under the title, clamped at one line */29 subtitle?: ReactNode;30 /** Optional row on the card's bottom edge (a price, a status badge); keep it non-interactive */31 meta?: ReactNode;32 /**33 * Makes the whole card one link, named by the title. Omit for a static card34 * with no hover and no focus stop.35 */36 href?: string;37 /**38 * Component that renders the link, for client-side routing (pass `next/link`).39 * Receives `href` and `className`. Defaults to a plain `a`.40 */41 linkComponent?: ElementType;42 /** Label for the failed-image fallback, read before the image's alt text. Defaults to "Image unavailable"; pass your translation. */43 unavailableLabel?: string;44 /** Aspect ratio of the media box, held before the image loads. Defaults to `4/5`. */45 aspectRatio?: ImageCardAspectRatio;46 /** Which heading tag (`h1`-`h6`) wraps the title. Defaults to `3`. */47 headingLevel?: CardHeadingLevel;48 /** Additional class names to merge with the component root element */49 className?: string;50}5152const DEFAULT_UNAVAILABLE_LABEL = "Image unavailable";5354/**55 * A card with an image on top and a title, subtitle and meta row below. It56 * composes `atoms.card` with no padding, so fill, border and corners are57 * Card's. With `href` the whole card is one link named by the title.58 */59export const ImageCard = ({60 media,61 title,62 subtitle,63 meta,64 href,65 linkComponent: LinkTag = "a",66 unavailableLabel = DEFAULT_UNAVAILABLE_LABEL,67 aspectRatio = "4/5",68 headingLevel = 3,69 className,70 ...rest71}: ImageCardProps) => {72 const HeadingTag = `h${headingLevel}` as ElementType;73 const isLinked = href !== undefined;7475 const [rootClass, getChildClass] = getClassName({76 className,77 rootClass: "BHT__ImageCard",78 modifiers: { linked: isLinked },79 styles,80 });8182 return (83 <Card {...rest} className={rootClass} padding="none">84 <div className={getChildClass("content")}>85 <ImageCardMedia86 media={media}87 unavailableLabel={unavailableLabel}88 aspectRatio={aspectRatio}89 />90 <div className={getChildClass("body")}>91 <HeadingTag className={getChildClass("title")}>92 {isLinked ? (93 <LinkTag href={href} className={getChildClass("link")}>94 {title}95 </LinkTag>96 ) : (97 title98 )}99 </HeadingTag>100 {subtitle !== undefined && (101 <p className={getChildClass("subtitle")}>{subtitle}</p>102 )}103 {meta !== undefined && (104 <div className={getChildClass("meta")}>{meta}</div>105 )}106 </div>107 </div>108 </Card>109 );110};
image-card-media.module.scss
1// The media box holds its ratio before the image paints, so nothing below shifts.2.BHT__ImageCardMedia {3 position: relative;4 overflow: hidden;5 flex-shrink: 0;6 width: 100%;7 background: var(--md-sys-color-surface-container-highest);89 img,10 picture {11 display: block;12 width: 100%;13 height: 100%;14 object-fit: cover;15 }1617 &__fallback {18 position: absolute;19 inset: 0;20 display: flex;21 align-items: center;22 justify-content: center;23 color: var(--md-sys-color-on-surface-variant);24 }2526 &--aspect-4-5 {27 aspect-ratio: 4 / 5;28 }2930 &--aspect-1-1 {31 aspect-ratio: 1 / 1;32 }3334 &--aspect-3-2 {35 aspect-ratio: 3 / 2;36 }37}
image-card.module.scss
1// Composes atoms.card (padding none), which owns fill, border and corners.2// This root class is merged onto Card's root, so these rules style the card3// itself, not Card's inner classes.45.BHT__ImageCard {6 position: relative;7 overflow: hidden;8 // Fills the grid row so metas line up across a row. Card's body is its only9 // child; a one-track grid stretches it to the card's height.10 display: grid;11 grid-template-rows: minmax(0, 1fr);12 height: 100%;13 transition: box-shadow var(--md-sys-motion-duration-short-2, 100ms)14 var(--md-sys-motion-easing-standard, ease);1516 &__content {17 display: flex;18 flex-direction: column;19 height: 100%;20 }2122 &__body {23 display: flex;24 flex: 1;25 flex-direction: column;26 padding: var(--spacing-4);27 text-align: start;28 }2930 &__title {31 margin: 0;32 font-family: var(--md-sys-typescale-title-medium-font, var(--font-heading));33 font-size: var(--md-sys-typescale-title-medium-size, 1rem);34 font-weight: var(--md-sys-typescale-title-medium-weight, 500);35 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);36 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.0094em);37 color: var(--md-sys-color-on-surface);38 overflow-wrap: anywhere;39 display: -webkit-box;40 -webkit-box-orient: vertical;41 -webkit-line-clamp: 2;42 line-clamp: 2;43 overflow: hidden;44 }4546 &__subtitle {47 margin: var(--spacing-1) 0 0;48 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));49 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);50 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);51 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);52 color: var(--md-sys-color-on-surface-variant);53 display: -webkit-box;54 -webkit-box-orient: vertical;55 -webkit-line-clamp: 1;56 line-clamp: 1;57 overflow: hidden;58 }5960 // Bottom edge of the card; wraps when the row is narrow.61 &__meta {62 display: flex;63 flex-wrap: wrap;64 align-items: center;65 justify-content: space-between;66 gap: var(--spacing-2);67 margin-top: auto;68 padding-top: var(--spacing-3);69 }7071 &__link {72 color: inherit;73 text-decoration: none;74 // The ring is drawn on ::before below, not on the link's own box.75 outline: none;7677 // The link's hit area is the whole card, so one link carries the one78 // name. ::after is also the state layer, as on Card.79 &::after {80 content: "";81 position: absolute;82 inset: 0;83 background: var(--md-sys-color-on-surface);84 opacity: 0;85 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)86 var(--md-sys-motion-easing-standard, ease);87 }8889 // The focus ring is its own full-card layer: opacity on ::after would90 // fade the ring with the state layer. Inset, like Card's interactive91 // body, because the root clips an outside ring.92 &::before {93 content: "";94 position: absolute;95 inset: 0;96 z-index: 1;97 pointer-events: none;98 }99100 &:hover::after {101 opacity: var(--md-sys-state-hover-opacity, 0.08);102 }103104 &:focus-visible::after {105 opacity: var(--md-sys-state-focus-opacity, 0.1);106 }107108 &:focus-visible::before {109 outline: 3px solid110 var(--md-sys-color-secondary, var(--color-border-focus));111 outline-offset: -3px;112 }113114 &:active::after {115 opacity: var(--md-sys-state-pressed-opacity, 0.1);116 }117 }118119 // The glow is on the card (an object), never on text; transparent in Light.120 &--linked:hover {121 box-shadow: 0 0 24px var(--brand-glow-violet, transparent);122 }123}
index.ts
1export { ImageCard } from "./image-card.js";2export type { ImageCardProps, ImageCardAspectRatio } from "./image-card.js";
image-card.example.tsx
1import { ImageCard } from "./image-card.js";23const PLACEHOLDER_IMAGE =4 "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='400' height='500'><rect width='400' height='500' fill='%23bc13fe'/></svg>";5const BROKEN_IMAGE = "https://invalid.example/missing.jpg";67/** A linked artwork card: the whole card is one link named by the title. */8export const LinkedImageCard = () => (9 <ImageCard10 href="/art/blue-hour"11 media={12 <img13 src={PLACEHOLDER_IMAGE}14 alt="Blue hour on the porch, oil on canvas"15 />16 }17 title="Blue hour on the porch"18 subtitle="Lena Park"19 meta="$480.00"20 />21);2223/** A static card with a square media box, as for an artist tile. */24export const SquareImageCard = () => (25 <ImageCard26 aspectRatio="1/1"27 media={<img src={PLACEHOLDER_IMAGE} alt="Lena Park in her studio" />}28 title="Lena Park"29 />30);3132/** A 3/2 media box, as for a booking preview. */33export const WideImageCard = () => (34 <ImageCard35 aspectRatio="3/2"36 href="/bookings/studio-visit"37 media={<img src={PLACEHOLDER_IMAGE} alt="The studio, morning light" />}38 title="Studio visit"39 subtitle="Saturday, 10:00"40 />41);4243/** An image that fails to load shows the image icon at the same ratio. */44export const FailedImageCard = () => (45 <ImageCard46 media={47 <img src={BROKEN_IMAGE} alt="Hive study no. 3, the garden at dusk" />48 }49 title="Hive study no. 3, the garden at dusk"50 subtitle="Theo Grant"51 meta="$1,250.00"52 />53);5455/** Cards in a responsive grid row: two across from a 352px container, more as it widens, with a long title and a missing subtitle. */56export const ImageCardGridRow = () => (57 <div58 style={{59 display: "grid",60 gridTemplateColumns: "repeat(auto-fill, minmax(10.5rem, 1fr))",61 gap: "1rem",62 }}63 >64 <ImageCard65 href="/art/blue-hour"66 media={<img src={PLACEHOLDER_IMAGE} alt="Blue hour on the porch" />}67 title="Blue hour on the porch"68 subtitle="Lena Park"69 meta="$480.00"70 />71 <ImageCard72 href="/art/hive-study-3"73 media={<img src={PLACEHOLDER_IMAGE} alt="Hive study no. 3" />}74 title="Hive study no. 3, the garden at dusk, with a title long enough to clamp"75 subtitle="Theo Grant"76 meta="$1,250.00"77 />78 <ImageCard79 href="/art/morning-wall"80 media={<img src={PLACEHOLDER_IMAGE} alt="Morning wall" />}81 title="Morning wall"82 />83 </div>84);
image-card.spec.tsx
1import { fireEvent, render, screen, within } from "@testing-library/react";2import { beforeAll, describe, it, expect } from "vitest";3import { ImageCard } from "./image-card.js";45const ALT = "Blue hour on the porch, oil on canvas";6const TITLE = "Blue hour on the porch";78const renderCard = (props: Partial<Parameters<typeof ImageCard>[0]> = {}) =>9 render(10 <ImageCard11 media={<img src="/blue-hour.jpg" alt={ALT} />}12 title={TITLE}13 subtitle="Lena Park"14 meta="$480.00"15 {...props}16 />,17 );1819// jsdom never loads images, so `complete` and `naturalWidth` are stubbed: an20// image has finished only when its src says it broke before React attached.21beforeAll(() => {22 Object.defineProperty(HTMLImageElement.prototype, "complete", {23 configurable: true,24 get(this: HTMLImageElement) {25 return this.src.endsWith("/broken.jpg");26 },27 });28 Object.defineProperty(HTMLImageElement.prototype, "naturalWidth", {29 configurable: true,30 get: () => 0,31 });32});3334describe("ImageCard", () => {35 it("renders one link named by the title when href is set", () => {36 renderCard({ href: "/art/blue-hour" });37 const links = screen.getAllByRole("link");38 expect(links).toHaveLength(1);39 expect(links[0]).toHaveAccessibleName(TITLE);40 expect(links[0]).toHaveAttribute("href", "/art/blue-hour");41 });4243 it("renders no link without href", () => {44 renderCard();45 expect(screen.queryByRole("link")).toBeNull();46 });4748 it("renders through linkComponent", () => {49 const Custom = ({50 href,51 className,52 children,53 }: {54 href: string;55 className?: string;56 children?: React.ReactNode;57 }) => (58 <a data-custom="yes" href={href} className={className}>59 {children}60 </a>61 );62 renderCard({ href: "/x", linkComponent: Custom });63 expect(screen.getByRole("link", { name: TITLE })).toHaveAttribute(64 "data-custom",65 "yes",66 );67 });6869 it("wraps the title in the requested heading level", () => {70 renderCard({ headingLevel: 2, href: "/x" });71 const heading = screen.getByRole("heading", { level: 2, name: TITLE });72 expect(within(heading).getByRole("link")).toBeTruthy();73 });7475 it("defaults the heading to level 3", () => {76 renderCard();77 expect(screen.getByRole("heading", { level: 3, name: TITLE })).toBeTruthy();78 });7980 it("keeps the image's descriptive alt text", () => {81 renderCard();82 expect(screen.getByRole("img", { name: ALT })).toBeTruthy();83 });8485 // jsdom applies no stylesheet, so the unit test checks the ratio's modifier;86 // the computed `aspect-ratio` is asserted in apps/docs/e2e/image-card.spec.ts.87 it.each([88 ["4/5", "aspect-4-5"],89 ["1/1", "aspect-1-1"],90 ["3/2", "aspect-3-2"],91 ] as const)("maps aspectRatio %s to its modifier", (ratio, modifier) => {92 const { container } = renderCard({ aspectRatio: ratio });93 const media = container.querySelector(94 '[class*="BHT__ImageCardMedia"]',95 ) as HTMLElement;96 expect(media.className).toContain(`BHT__ImageCardMedia--${modifier}`);97 });9899 it("shows the image icon fallback in the same media box when the image fails", () => {100 const { container } = renderCard({ aspectRatio: "3/2" });101 fireEvent.error(screen.getByRole("img", { name: ALT }));102 const media = container.querySelector(103 '[class*="BHT__ImageCardMedia"]',104 ) as HTMLElement;105 expect(media.querySelector('[class*="__fallback"] svg')).toBeTruthy();106 expect(media.className).toContain("BHT__ImageCardMedia--aspect-3-2");107 });108109 it("names the failed image's alt text and that it is unavailable", () => {110 renderCard();111 fireEvent.error(screen.getByRole("img", { name: ALT }));112 expect(113 screen.getByRole("img", { name: `Image unavailable: ${ALT}` }),114 ).toBeTruthy();115 });116117 it("uses unavailableLabel for the fallback's name", () => {118 renderCard({ unavailableLabel: "Bild nicht verfügbar" });119 fireEvent.error(screen.getByRole("img", { name: ALT }));120 expect(121 screen.getByRole("img", { name: `Bild nicht verfügbar: ${ALT}` }),122 ).toBeTruthy();123 });124125 it("shows the fallback for an image that broke before React attached", () => {126 renderCard({ media: <img src="/broken.jpg" alt={ALT} /> });127 expect(128 screen.getByRole("img", { name: `Image unavailable: ${ALT}` }),129 ).toBeTruthy();130 });131132 it("clears the failure when the media changes to another image", () => {133 const { rerender } = renderCard();134 fireEvent.error(screen.getByRole("img", { name: ALT }));135 rerender(136 <ImageCard137 media={<img src="/other.jpg" alt="Another painting" />}138 title="Other"139 />,140 );141 expect(screen.getByRole("img", { name: "Another painting" })).toBeTruthy();142 });143144 it("clears a wrapped image's failure only when the card is keyed", () => {145 const wrapped = (src: string) => (146 <picture>147 <img src={src} alt={ALT} />148 </picture>149 );150 const { rerender } = renderCard({ media: wrapped("/a.jpg") });151 fireEvent.error(screen.getByRole("img", { name: ALT }));152 rerender(<ImageCard media={wrapped("/b.jpg")} title={TITLE} />);153 expect(screen.queryByRole("img", { name: ALT })).toBeNull();154 rerender(<ImageCard key="b" media={wrapped("/b.jpg")} title={TITLE} />);155 expect(screen.getByRole("img", { name: ALT })).toBeTruthy();156 });157158 it("keeps the title, subtitle and meta when the image fails", () => {159 renderCard();160 fireEvent.error(screen.getByRole("img", { name: ALT }));161 expect(screen.getByText("Lena Park")).toBeTruthy();162 expect(screen.getByText("$480.00")).toBeTruthy();163 });164165 it("renders the meta slot, which keeps a sold piece's price", () => {166 renderCard({ meta: <span>$1,250.00</span> });167 expect(screen.getByText("$1,250.00")).toBeTruthy();168 });169170 it("omits subtitle and meta when not given", () => {171 const { container } = render(172 <ImageCard media={<img src="/a.jpg" alt="A" />} title="Only" />,173 );174 expect(container.querySelector('[class*="__subtitle"]')).toBeNull();175 expect(container.querySelector('[class*="__meta"]')).toBeNull();176 });177178 it("merges className onto the root", () => {179 const { container } = renderCard({ className: "extra" });180 expect(container.firstChild).toHaveClass("extra");181 });182});