Image Card
v0.1.2DraftA 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/atoms.icon
image-card.tsx
1import {2 isValidElement,3 type ElementType,4 type HTMLAttributes,5 type ReactNode,6} from "react";7import { getClassName } from "@bht-component-registry/class-names";8import { Card, type CardHeadingLevel } from "@behivetech/atoms.card";9import { ImageIcon } from "@behivetech/atoms.icon";10import styles from "./image-card.module.scss";11import { useImageStatus } from "./use-image-status.js";1213/** The media box ratios the design allows, written as CSS ratios. */14export type ImageCardAspectRatio = "4/5" | "1/1" | "3/2";1516export interface ImageCardProps extends Omit<17 HTMLAttributes<HTMLDivElement>,18 "title" | "onSelect"19> {20 /**21 * The image, or any node that holds one (`next/image` with `fill` works).22 * Give the image its descriptive `alt`. If an `<img>` inside fails to load,23 * including before hydration, the media box swaps it for an image icon at24 * the same aspect ratio, labelled with `unavailableLabel` and the alt text.25 * A new `src` on the image clears the failure.26 */27 media: ReactNode;28 /** Card title, clamped at two lines. It names the link when `href` is set. */29 title: ReactNode;30 /** Optional line under the title, clamped at one line */31 subtitle?: ReactNode;32 /** Optional row on the card's bottom edge (a price, a status badge); keep it non-interactive */33 meta?: ReactNode;34 /**35 * Makes the whole card one link, named by the title. Omit for a static card36 * with no hover and no focus stop.37 */38 href?: string;39 /**40 * Component that renders the link, for client-side routing (pass `next/link`).41 * Receives `href` and `className`. Defaults to a plain `a`.42 */43 linkComponent?: ElementType;44 /** Label for the failed-image fallback, read before the image's alt text. Defaults to "Image unavailable"; pass your translation. */45 unavailableLabel?: string;46 /** Aspect ratio of the media box, held before the image loads. Defaults to `4/5`. */47 aspectRatio?: ImageCardAspectRatio;48 /** Which heading tag (`h1`-`h6`) wraps the title. Defaults to `3`. */49 headingLevel?: CardHeadingLevel;50 /** Additional class names to merge with the component root element */51 className?: string;52}5354/** Modifier per ratio; a lookup, so a new ratio is one key here and one rule in the stylesheet. */55const ASPECT_MODIFIERS: Record<ImageCardAspectRatio, string> = {56 "4/5": "aspect-4-5",57 "1/1": "aspect-1-1",58 "3/2": "aspect-3-2",59};6061const DEFAULT_UNAVAILABLE_LABEL = "Image unavailable";6263/** Names the image inside `media` so a changed image clears an earlier failure. */64const getMediaKey = (media: ReactNode): string => {65 let key = "";66 if (isValidElement<{ src?: string | { src?: string } }>(media)) {67 const { src } = media.props;68 key = (typeof src === "string" ? src : src?.src) ?? "";69 }70 return key;71};7273/**74 * A card with an image on top and a title, subtitle and meta row below. It75 * composes `atoms.card` with no padding, so fill, border and corners are76 * Card's. With `href` the whole card is one link named by the title.77 */78export const ImageCard = ({79 media,80 title,81 subtitle,82 meta,83 href,84 linkComponent: LinkTag = "a",85 unavailableLabel = DEFAULT_UNAVAILABLE_LABEL,86 aspectRatio = "4/5",87 headingLevel = 3,88 className,89 ...rest90}: ImageCardProps) => {91 const { ref, hasFailed, alt, handlers } = useImageStatus(getMediaKey(media));92 const HeadingTag = `h${headingLevel}` as ElementType;93 const isLinked = href !== undefined;9495 const [rootClass, getChildClass] = getClassName({96 className,97 rootClass: "BHT__ImageCard",98 modifiers: { [ASPECT_MODIFIERS[aspectRatio]]: true, linked: isLinked },99 styles,100 });101102 return (103 <Card {...rest} className={rootClass} padding="none">104 <div className={getChildClass("content")}>105 <div ref={ref} className={getChildClass("media")} {...handlers}>106 {hasFailed ? (107 <span108 className={getChildClass("fallback")}109 role="img"110 aria-label={111 alt ? `${unavailableLabel}: ${alt}` : unavailableLabel112 }113 >114 <ImageIcon size="xl" aria-hidden="true" />115 </span>116 ) : (117 media118 )}119 </div>120 <div className={getChildClass("body")}>121 <HeadingTag className={getChildClass("title")}>122 {isLinked ? (123 <LinkTag href={href} className={getChildClass("link")}>124 {title}125 </LinkTag>126 ) : (127 title128 )}129 </HeadingTag>130 {subtitle !== undefined && (131 <p className={getChildClass("subtitle")}>{subtitle}</p>132 )}133 {meta !== undefined && (134 <div className={getChildClass("meta")}>{meta}</div>135 )}136 </div>137 </div>138 </Card>139 );140};
use-image-status.ts
1import {2 useEffect,3 useRef,4 useState,5 type ReactEventHandler,6 type RefObject,7} from "react";89interface ImageFailure {10 /** The image the failure belongs to; a new key starts again with no failure */11 key: string;12 /** The failed image's alt text, kept so the fallback can still describe it */13 alt: string;14}1516interface ImageFailureResult {17 /** Attach to the element that contains the rendered `<img>` */18 ref: RefObject<HTMLDivElement | null>;19 hasFailed: boolean;20 /** Alt text of the image that failed, or an empty string */21 alt: string;22 /** Spread on the same element: error does not bubble, so React's capture phase catches it */23 handlers: { onErrorCapture: ReactEventHandler };24}2526/**27 * Tracks whether the `<img>` inside a wrapper failed to load, whatever28 * component drew it (a plain `<img>` or an app's `next/image`). `resetKey`29 * names the image: a new key clears the failure. An image that broke before30 * hydration fires no event, so a mount also reads `complete` and `naturalWidth`.31 */32export const useImageStatus = (resetKey: string): ImageFailureResult => {33 const ref = useRef<HTMLDivElement | null>(null);34 const [failure, setFailure] = useState<ImageFailure | null>(null);35 const hasFailed = failure !== null && failure.key === resetKey;3637 useEffect(() => {38 const img = ref.current?.querySelector("img");3940 // A finished image with no pixels (a 404 under SSR) fired its error before React attached.41 if (img?.complete && img.naturalWidth === 0) {42 setFailure({ key: resetKey, alt: img.alt });43 }44 }, [resetKey]);4546 return {47 ref,48 hasFailed,49 alt: hasFailed ? failure.alt : "",50 handlers: {51 onErrorCapture: (event) => {52 if (event.target instanceof HTMLImageElement) {53 setFailure({ key: resetKey, alt: event.target.alt });54 }55 },56 },57 };58};
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 // The box holds its ratio before the image paints, so nothing below shifts.23 &__media {24 position: relative;25 overflow: hidden;26 flex-shrink: 0;27 width: 100%;28 background: var(--md-sys-color-surface-container-highest);2930 img,31 picture {32 display: block;33 width: 100%;34 height: 100%;35 object-fit: cover;36 }37 }3839 &__fallback {40 position: absolute;41 inset: 0;42 display: flex;43 align-items: center;44 justify-content: center;45 color: var(--md-sys-color-on-surface-variant);46 }4748 &__body {49 display: flex;50 flex: 1;51 flex-direction: column;52 padding: var(--spacing-4);53 text-align: start;54 }5556 &__title {57 margin: 0;58 font-family: var(--md-sys-typescale-title-medium-font, var(--font-heading));59 font-size: var(--md-sys-typescale-title-medium-size, 1rem);60 font-weight: var(--md-sys-typescale-title-medium-weight, 500);61 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);62 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.0094em);63 color: var(--md-sys-color-on-surface);64 overflow-wrap: anywhere;65 display: -webkit-box;66 -webkit-box-orient: vertical;67 -webkit-line-clamp: 2;68 line-clamp: 2;69 overflow: hidden;70 }7172 &__subtitle {73 margin: var(--spacing-1) 0 0;74 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));75 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);76 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);77 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);78 color: var(--md-sys-color-on-surface-variant);79 display: -webkit-box;80 -webkit-box-orient: vertical;81 -webkit-line-clamp: 1;82 line-clamp: 1;83 overflow: hidden;84 }8586 // Bottom edge of the card; wraps when the row is narrow.87 &__meta {88 display: flex;89 flex-wrap: wrap;90 align-items: center;91 justify-content: space-between;92 gap: var(--spacing-2);93 margin-top: auto;94 padding-top: var(--spacing-3);95 }9697 &__link {98 color: inherit;99 text-decoration: none;100 // The ring is drawn on ::before below, not on the link's own box.101 outline: none;102103 // The link's hit area is the whole card, so one link carries the one104 // name. ::after is also the state layer, as on Card.105 &::after {106 content: "";107 position: absolute;108 inset: 0;109 background: var(--md-sys-color-on-surface);110 opacity: 0;111 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)112 var(--md-sys-motion-easing-standard, ease);113 }114115 // The focus ring is its own full-card layer: opacity on ::after would116 // fade the ring with the state layer. Inset, like Card's interactive117 // body, because the root clips an outside ring.118 &::before {119 content: "";120 position: absolute;121 inset: 0;122 z-index: 1;123 pointer-events: none;124 }125126 &:hover::after {127 opacity: var(--md-sys-state-hover-opacity, 0.08);128 }129130 &:focus-visible::after {131 opacity: var(--md-sys-state-focus-opacity, 0.1);132 }133134 &:focus-visible::before {135 outline: 3px solid136 var(--md-sys-color-secondary, var(--color-border-focus));137 outline-offset: -3px;138 }139140 &:active::after {141 opacity: var(--md-sys-state-pressed-opacity, 0.1);142 }143 }144145 // The glow is on the card (an object), never on text; transparent in Light.146 &--linked:hover {147 box-shadow: 0 0 24px var(--brand-glow-violet, transparent);148 }149150 &--aspect-4-5 .BHT__ImageCard__media {151 aspect-ratio: 4 / 5;152 }153154 &--aspect-1-1 .BHT__ImageCard__media {155 aspect-ratio: 1 / 1;156 }157158 &--aspect-3-2 .BHT__ImageCard__media {159 aspect-ratio: 3 / 2;160 }161}
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/** Three cards in a row at a grid column's width, with a long title and a missing subtitle. */56export const ImageCardGridRow = () => (57 <div58 style={{59 display: "grid",60 gridTemplateColumns: "repeat(3, 17.5rem)",61 gap: "1.5rem",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 expect((container.firstChild as HTMLElement).className).toContain(94 `BHT__ImageCard--${modifier}`,95 );96 });9798 it("shows the image icon fallback in the same media box when the image fails", () => {99 const { container } = renderCard({ aspectRatio: "3/2" });100 fireEvent.error(screen.getByRole("img", { name: ALT }));101 const media = container.querySelector('[class*="__media"]') as HTMLElement;102 expect(media.querySelector('[class*="__fallback"] svg')).toBeTruthy();103 expect((container.firstChild as HTMLElement).className).toContain(104 "BHT__ImageCard--aspect-3-2",105 );106 });107108 it("names the failed image's alt text and that it is unavailable", () => {109 renderCard();110 fireEvent.error(screen.getByRole("img", { name: ALT }));111 expect(112 screen.getByRole("img", { name: `Image unavailable: ${ALT}` }),113 ).toBeTruthy();114 });115116 it("uses unavailableLabel for the fallback's name", () => {117 renderCard({ unavailableLabel: "Bild nicht verfügbar" });118 fireEvent.error(screen.getByRole("img", { name: ALT }));119 expect(120 screen.getByRole("img", { name: `Bild nicht verfügbar: ${ALT}` }),121 ).toBeTruthy();122 });123124 it("shows the fallback for an image that broke before React attached", () => {125 renderCard({ media: <img src="/broken.jpg" alt={ALT} /> });126 expect(127 screen.getByRole("img", { name: `Image unavailable: ${ALT}` }),128 ).toBeTruthy();129 });130131 it("clears the failure when the media changes to another image", () => {132 const { rerender } = renderCard();133 fireEvent.error(screen.getByRole("img", { name: ALT }));134 rerender(135 <ImageCard136 media={<img src="/other.jpg" alt="Another painting" />}137 title="Other"138 />,139 );140 expect(screen.getByRole("img", { name: "Another painting" })).toBeTruthy();141 });142143 it("keeps the title, subtitle and meta when the image fails", () => {144 renderCard();145 fireEvent.error(screen.getByRole("img", { name: ALT }));146 expect(screen.getByText("Lena Park")).toBeTruthy();147 expect(screen.getByText("$480.00")).toBeTruthy();148 });149150 it("renders the meta slot, which keeps a sold piece's price", () => {151 renderCard({ meta: <span>$1,250.00</span> });152 expect(screen.getByText("$1,250.00")).toBeTruthy();153 });154155 it("omits subtitle and meta when not given", () => {156 const { container } = render(157 <ImageCard media={<img src="/a.jpg" alt="A" />} title="Only" />,158 );159 expect(container.querySelector('[class*="__subtitle"]')).toBeNull();160 expect(container.querySelector('[class*="__meta"]')).toBeNull();161 });162163 it("merges className onto the root", () => {164 const { container } = renderCard({ className: "extra" });165 expect(container.firstChild).toHaveClass("extra");166 });167});