Behive Tech Component Registry

Image Card

v0.1.2Draft

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.

pnpm add @behivetech/molecules.image-card

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";
12
13/** The media box ratios the design allows, written as CSS ratios. */
14export type ImageCardAspectRatio = "4/5" | "1/1" | "3/2";
15
16export 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 at
24 * 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 card
36 * 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}
53
54/** 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};
60
61const DEFAULT_UNAVAILABLE_LABEL = "Image unavailable";
62
63/** 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};
72
73/**
74 * A card with an image on top and a title, subtitle and meta row below. It
75 * composes `atoms.card` with no padding, so fill, border and corners are
76 * 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 ...rest
90}: ImageCardProps) => {
91 const { ref, hasFailed, alt, handlers } = useImageStatus(getMediaKey(media));
92 const HeadingTag = `h${headingLevel}` as ElementType;
93 const isLinked = href !== undefined;
94
95 const [rootClass, getChildClass] = getClassName({
96 className,
97 rootClass: "BHT__ImageCard",
98 modifiers: { [ASPECT_MODIFIERS[aspectRatio]]: true, linked: isLinked },
99 styles,
100 });
101
102 return (
103 <Card {...rest} className={rootClass} padding="none">
104 <div className={getChildClass("content")}>
105 <div ref={ref} className={getChildClass("media")} {...handlers}>
106 {hasFailed ? (
107 <span
108 className={getChildClass("fallback")}
109 role="img"
110 aria-label={
111 alt ? `${unavailableLabel}: ${alt}` : unavailableLabel
112 }
113 >
114 <ImageIcon size="xl" aria-hidden="true" />
115 </span>
116 ) : (
117 media
118 )}
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 title
128 )}
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";
8
9interface 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}
15
16interface 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}
25
26/**
27 * Tracks whether the `<img>` inside a wrapper failed to load, whatever
28 * 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 before
30 * 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;
36
37 useEffect(() => {
38 const img = ref.current?.querySelector("img");
39
40 // 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]);
45
46 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 card
3// itself, not Card's inner classes.
4
5.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 only
9 // 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);
15
16 &__content {
17 display: flex;
18 flex-direction: column;
19 height: 100%;
20 }
21
22 // 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);
29
30 img,
31 picture {
32 display: block;
33 width: 100%;
34 height: 100%;
35 object-fit: cover;
36 }
37 }
38
39 &__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 }
47
48 &__body {
49 display: flex;
50 flex: 1;
51 flex-direction: column;
52 padding: var(--spacing-4);
53 text-align: start;
54 }
55
56 &__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 }
71
72 &__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 }
85
86 // 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 }
96
97 &__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;
102
103 // The link's hit area is the whole card, so one link carries the one
104 // 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 }
114
115 // The focus ring is its own full-card layer: opacity on ::after would
116 // fade the ring with the state layer. Inset, like Card's interactive
117 // 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 }
125
126 &:hover::after {
127 opacity: var(--md-sys-state-hover-opacity, 0.08);
128 }
129
130 &:focus-visible::after {
131 opacity: var(--md-sys-state-focus-opacity, 0.1);
132 }
133
134 &:focus-visible::before {
135 outline: 3px solid
136 var(--md-sys-color-secondary, var(--color-border-focus));
137 outline-offset: -3px;
138 }
139
140 &:active::after {
141 opacity: var(--md-sys-state-pressed-opacity, 0.1);
142 }
143 }
144
145 // 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 }
149
150 &--aspect-4-5 .BHT__ImageCard__media {
151 aspect-ratio: 4 / 5;
152 }
153
154 &--aspect-1-1 .BHT__ImageCard__media {
155 aspect-ratio: 1 / 1;
156 }
157
158 &--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";
2
3const 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";
6
7/** A linked artwork card: the whole card is one link named by the title. */
8export const LinkedImageCard = () => (
9 <ImageCard
10 href="/art/blue-hour"
11 media={
12 <img
13 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);
22
23/** A static card with a square media box, as for an artist tile. */
24export const SquareImageCard = () => (
25 <ImageCard
26 aspectRatio="1/1"
27 media={<img src={PLACEHOLDER_IMAGE} alt="Lena Park in her studio" />}
28 title="Lena Park"
29 />
30);
31
32/** A 3/2 media box, as for a booking preview. */
33export const WideImageCard = () => (
34 <ImageCard
35 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);
42
43/** An image that fails to load shows the image icon at the same ratio. */
44export const FailedImageCard = () => (
45 <ImageCard
46 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);
54
55/** Three cards in a row at a grid column's width, with a long title and a missing subtitle. */
56export const ImageCardGridRow = () => (
57 <div
58 style={{
59 display: "grid",
60 gridTemplateColumns: "repeat(3, 17.5rem)",
61 gap: "1.5rem",
62 }}
63 >
64 <ImageCard
65 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 <ImageCard
72 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 <ImageCard
79 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";
4
5const ALT = "Blue hour on the porch, oil on canvas";
6const TITLE = "Blue hour on the porch";
7
8const renderCard = (props: Partial<Parameters<typeof ImageCard>[0]> = {}) =>
9 render(
10 <ImageCard
11 media={<img src="/blue-hour.jpg" alt={ALT} />}
12 title={TITLE}
13 subtitle="Lena Park"
14 meta="$480.00"
15 {...props}
16 />,
17 );
18
19// jsdom never loads images, so `complete` and `naturalWidth` are stubbed: an
20// 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});
33
34describe("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 });
42
43 it("renders no link without href", () => {
44 renderCard();
45 expect(screen.queryByRole("link")).toBeNull();
46 });
47
48 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 });
68
69 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 });
74
75 it("defaults the heading to level 3", () => {
76 renderCard();
77 expect(screen.getByRole("heading", { level: 3, name: TITLE })).toBeTruthy();
78 });
79
80 it("keeps the image's descriptive alt text", () => {
81 renderCard();
82 expect(screen.getByRole("img", { name: ALT })).toBeTruthy();
83 });
84
85 // 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 });
97
98 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 });
107
108 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 });
115
116 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 });
123
124 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 });
130
131 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 <ImageCard
136 media={<img src="/other.jpg" alt="Another painting" />}
137 title="Other"
138 />,
139 );
140 expect(screen.getByRole("img", { name: "Another painting" })).toBeTruthy();
141 });
142
143 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 });
149
150 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 });
154
155 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 });
162
163 it("merges className onto the root", () => {
164 const { container } = renderCard({ className: "extra" });
165 expect(container.firstChild).toHaveClass("extra");
166 });
167});