Behive Tech Component Registry

Gallery

v0.1.4Draft

A responsive masonry image grid that opens a swipeable, keyboard-navigable lightbox. Tiles keep each image's ratio, captions show in the lightbox only, and the lightbox is a fullscreen `Modal`.

pnpm add @behivetech/organisms.gallery

registry/behivetech/organisms/gallery · depends on @behivetech/atoms.button, @behivetech/atoms.icon, @behivetech/atoms.modal, @behivetech/atoms.spinner

gallery-active-lightbox.tsx
1import { GalleryLightbox } from "./gallery-lightbox.js";
2import {
3 type GalleryImage,
4 type GalleryLabels,
5 type GalleryRenderImage,
6} from "./gallery-types.js";
7import { type LightboxState } from "./use-lightbox.js";
8
9export interface GalleryActiveLightboxProps {
10 /** All the images; the open one is shown */
11 images: GalleryImage[];
12 /** Every text, already merged with the defaults */
13 labels: GalleryLabels;
14 /** Draws the image in the lightbox */
15 renderImage: GalleryRenderImage;
16 /** Says which image is open and handles navigation, close and focus return */
17 lightbox: LightboxState;
18}
19
20/** The lightbox for the image that is open, and nothing while none is. */
21export const GalleryActiveLightbox = ({
22 images,
23 labels,
24 renderImage,
25 lightbox,
26}: GalleryActiveLightboxProps) => {
27 const { openIndex } = lightbox;
28 const image = openIndex === null ? undefined : images[openIndex];
29
30 return image && openIndex !== null ? (
31 <GalleryLightbox
32 image={image}
33 total={images.length}
34 index={openIndex}
35 labels={labels}
36 renderImage={renderImage}
37 onIndexChange={lightbox.changeIndex}
38 onClose={lightbox.close}
39 onCloseAutoFocus={lightbox.restoreFocus}
40 />
41 ) : null;
42};
gallery-grid.tsx
1import { getClassName } from "@bht-component-registry/class-names";
2import { GalleryThumbnail } from "./gallery-thumbnail.js";
3import {
4 type GalleryBreakpoint,
5 type GalleryImage,
6 type GalleryLabels,
7 type GalleryRenderImage,
8} from "./gallery-types.js";
9import { thumbnailSizes } from "./masonry.js";
10import { type LightboxState } from "./use-lightbox.js";
11import styles from "./gallery.module.scss";
12
13export interface GalleryGridProps {
14 /** The images, in the order the list renders them */
15 images: GalleryImage[];
16 /** The resolved column count per breakpoint */
17 columns: Record<GalleryBreakpoint, number>;
18 /** Every text, already merged with the defaults */
19 labels: GalleryLabels;
20 /** Draws each thumbnail */
21 renderImage: GalleryRenderImage;
22 /** Opens the lightbox and registers each thumbnail for focus return */
23 lightbox: LightboxState;
24}
25
26/** The list of thumbnails, in `images[]` order. */
27export const GalleryGrid = ({
28 images,
29 columns,
30 labels,
31 renderImage,
32 lightbox,
33}: GalleryGridProps) => {
34 const [, getChildClass] = getClassName({ rootClass: "BHT__Gallery", styles });
35 const sizes = thumbnailSizes(columns);
36
37 return (
38 <ul role="list" className={getChildClass("list")}>
39 {images.map((image, index) => (
40 <GalleryThumbnail
41 key={`${image.src}:${index}`}
42 image={image}
43 index={index}
44 labels={labels}
45 renderImage={renderImage}
46 sizes={sizes}
47 onOpen={lightbox.open}
48 buttonRef={lightbox.registerThumbnail(index)}
49 />
50 ))}
51 </ul>
52 );
53};
gallery-labels.ts
1import { type GalleryLabels } from "./gallery-types.js";
2
3/** English defaults; pass `labels` to Gallery to translate any of them. */
4export const DEFAULT_LABELS: GalleryLabels = {
5 close: "Close",
6 previous: "Previous image",
7 next: "Next image",
8 image: "Image",
9 counter: "{current} of {total}",
10 loading: "Loading image",
11 unavailable: "Image unavailable",
12 failed: "This image could not be loaded.",
13};
gallery-lightbox-arrow.tsx
1import { getClassName } from "@bht-component-registry/class-names";
2import { Button } from "@behivetech/atoms.button";
3import { ArrowLeftIcon, ArrowRightIcon } from "@behivetech/atoms.icon";
4import { type GalleryLabels, type LightboxSteps } from "./gallery-types.js";
5import styles from "./gallery-lightbox.module.scss";
6
7type Direction = "previous" | "next";
8
9const ENABLED: Record<Direction, (nav: LightboxSteps) => boolean> = {
10 previous: (nav) => nav.canGoPrevious,
11 next: (nav) => nav.canGoNext,
12};
13
14const STEP: Record<Direction, (nav: LightboxSteps) => () => void> = {
15 previous: (nav) => nav.goPrevious,
16 next: (nav) => nav.goNext,
17};
18
19const ICONS: Record<Direction, typeof ArrowLeftIcon> = {
20 previous: ArrowLeftIcon,
21 next: ArrowRightIcon,
22};
23
24export interface GalleryLightboxArrowProps {
25 /** Which way the button steps */
26 direction: Direction;
27 labels: GalleryLabels;
28 /** What the lightbox can step to; Previous is off on the first image, Next on the last */
29 nav: LightboxSteps;
30}
31
32/** A Previous or Next button of the lightbox. */
33export const GalleryLightboxArrow = ({
34 direction,
35 labels,
36 nav,
37}: GalleryLightboxArrowProps) => {
38 const [, getChildClass] = getClassName({
39 rootClass: "BHT__GalleryLightbox",
40 styles,
41 });
42 const Icon = ICONS[direction];
43
44 return (
45 <Button
46 className={getChildClass(direction)}
47 variant="secondary"
48 size="lg"
49 iconOnly
50 aria-label={labels[direction]}
51 disabled={!ENABLED[direction](nav)}
52 onClick={STEP[direction](nav)}
53 >
54 <Icon />
55 </Button>
56 );
57};
gallery-lightbox-body.tsx
1import { useRef } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import { GalleryLightboxArrow } from "./gallery-lightbox-arrow.js";
4import { GalleryLightboxStage } from "./gallery-lightbox-stage.js";
5import {
6 type GalleryImage,
7 type GalleryLabels,
8 type GalleryRenderImage,
9 type LightboxNavOptions,
10 type LightboxSteps,
11} from "./gallery-types.js";
12import { useStrandedFocus } from "./use-stranded-focus.js";
13import styles from "./gallery-lightbox.module.scss";
14
15// Previous and Next exist unless the image showing is the first or the last.
16const getSteps = ({
17 index,
18 total,
19 onIndexChange,
20}: LightboxNavOptions): LightboxSteps => ({
21 canGoPrevious: index > 0,
22 canGoNext: index < total - 1,
23 goPrevious: () => onIndexChange(index - 1),
24 goNext: () => onIndexChange(index + 1),
25});
26
27export interface GalleryLightboxBodyProps {
28 image: GalleryImage;
29 /** Its position in `images[]` */
30 index: number;
31 /** How many images there are */
32 total: number;
33 labels: GalleryLabels;
34 renderImage: GalleryRenderImage;
35 onIndexChange: (index: number) => void;
36 /** The id the dialog's `aria-describedby` points at */
37 captionId: string;
38}
39
40/** What sits inside the lightbox dialog: Previous, the image, Next and the caption. */
41export const GalleryLightboxBody = ({
42 image,
43 index,
44 total,
45 labels,
46 renderImage,
47 onIndexChange,
48 captionId,
49}: GalleryLightboxBodyProps) => {
50 const [rootClass, getChildClass] = getClassName({
51 rootClass: "BHT__GalleryLightbox",
52 styles,
53 });
54 const rootRef = useRef<HTMLDivElement | null>(null);
55 const nav = getSteps({ index, total, onIndexChange });
56
57 useStrandedFocus(rootRef, nav.canGoPrevious, nav.canGoNext);
58
59 return (
60 <div ref={rootRef} className={rootClass}>
61 <GalleryLightboxArrow direction="previous" labels={labels} nav={nav} />
62 <GalleryLightboxStage
63 image={image}
64 index={index}
65 labels={labels}
66 renderImage={renderImage}
67 canGoPrevious={nav.canGoPrevious}
68 canGoNext={nav.canGoNext}
69 onPrevious={nav.goPrevious}
70 onNext={nav.goNext}
71 />
72 <GalleryLightboxArrow direction="next" labels={labels} nav={nav} />
73 {image.caption && (
74 <p id={captionId} className={getChildClass("caption")}>
75 {image.caption}
76 </p>
77 )}
78 </div>
79 );
80};
gallery-lightbox-stage.tsx
1import { getClassName } from "@bht-component-registry/class-names";
2import { ImageIcon } from "@behivetech/atoms.icon";
3import { Spinner } from "@behivetech/atoms.spinner";
4import {
5 type GalleryImage,
6 type GalleryLabels,
7 type GalleryRenderImage,
8} from "./gallery-types.js";
9import { useImageStatus } from "./use-image-status.js";
10import { useSwipe } from "./use-swipe.js";
11import styles from "./gallery-lightbox-stage.module.scss";
12
13/** The `sizes` string for the lightbox image: it fills the viewport */
14export const LIGHTBOX_SIZES = "100vw";
15
16export interface GalleryLightboxStageProps {
17 image: GalleryImage;
18 /** Position in `images[]`; a new index mounts a fresh image */
19 index: number;
20 labels: GalleryLabels;
21 renderImage: GalleryRenderImage;
22 canGoPrevious: boolean;
23 canGoNext: boolean;
24 onPrevious: () => void;
25 onNext: () => void;
26}
27
28/**
29 * The lightbox's image area and swipe surface. While an image loads it shows a
30 * placeholder of the image's own shape and a spinner; a failed image shows a
31 * message. The image is keyed by `index`, so the previous bitmap is never left
32 * on screen under the next image's counter.
33 */
34export const GalleryLightboxStage = ({
35 image,
36 index,
37 labels,
38 renderImage,
39 canGoPrevious,
40 canGoNext,
41 onPrevious,
42 onNext,
43}: GalleryLightboxStageProps) => {
44 const [rootClass, getChildClass] = getClassName({
45 rootClass: "BHT__GalleryLightboxStage",
46 styles,
47 });
48 const { ref, status, handlers } = useImageStatus(`${index}:${image.src}`);
49 const { trackRef, handlers: swipeHandlers } = useSwipe({
50 canGoPrevious,
51 canGoNext,
52 onPrevious,
53 onNext,
54 });
55
56 return (
57 <div
58 className={rootClass}
59 aria-busy={status === "loading"}
60 {...swipeHandlers}
61 >
62 <div ref={trackRef} className={getChildClass("track")}>
63 <div
64 key={index}
65 ref={ref}
66 className={getChildClass("image")}
67 {...handlers}
68 >
69 {status === "loading" && (
70 // Holds the image's shape (contain, like the image) until it loads.
71 <svg
72 className={getChildClass("placeholder")}
73 viewBox={`0 0 ${image.width} ${image.height}`}
74 preserveAspectRatio="xMidYMid meet"
75 aria-hidden="true"
76 focusable="false"
77 >
78 <rect
79 className={getChildClass("placeholder-fill")}
80 width={image.width}
81 height={image.height}
82 />
83 </svg>
84 )}
85 {status === "error" ? (
86 <p className={getChildClass("failed")}>
87 <ImageIcon size="lg" />
88 {labels.failed}
89 </p>
90 ) : (
91 renderImage(image, { context: "lightbox", sizes: LIGHTBOX_SIZES })
92 )}
93 </div>
94 {status === "loading" && (
95 <Spinner
96 className={getChildClass("spinner")}
97 size="lg"
98 label={labels.loading}
99 />
100 )}
101 </div>
102 </div>
103 );
104};
gallery-lightbox-title.tsx
1import { getClassName } from "@bht-component-registry/class-names";
2import { type GalleryLabels } from "./gallery-types.js";
3import styles from "./gallery-lightbox.module.scss";
4
5export interface GalleryLightboxTitleProps {
6 /** Its position in `images[]` */
7 index: number;
8 /** How many images there are */
9 total: number;
10 labels: GalleryLabels;
11}
12
13/**
14 * The dialog title: a polite live region reading "Image 4 of 12", so each
15 * change is announced.
16 */
17export const GalleryLightboxTitle = ({
18 index,
19 total,
20 labels,
21}: GalleryLightboxTitleProps) => {
22 const [, getChildClass] = getClassName({
23 rootClass: "BHT__GalleryLightbox",
24 styles,
25 });
26 const counter = labels.counter
27 .replace("{current}", String(index + 1))
28 .replace("{total}", String(total));
29
30 return (
31 <span aria-live="polite">
32 <span className={getChildClass("visually-hidden")}>{labels.image}</span>{" "}
33 {counter}
34 </span>
35 );
36};
gallery-lightbox.tsx
1import { useId, type KeyboardEvent } from "react";
2import { Modal } from "@behivetech/atoms.modal";
3import {
4 type GalleryImage,
5 type GalleryLabels,
6 type GalleryRenderImage,
7 type LightboxNavOptions,
8} from "./gallery-types.js";
9import { GalleryLightboxBody } from "./gallery-lightbox-body.js";
10import { GalleryLightboxTitle } from "./gallery-lightbox-title.js";
11
12// Where each navigation key goes, given the current and last index.
13const KEY_TARGETS: Record<string, (index: number, last: number) => number> = {
14 ArrowLeft: (index) => index - 1,
15 ArrowRight: (index) => index + 1,
16 Home: () => 0,
17 End: (_index, last) => last,
18};
19
20// The dialog's keydown handler: the target is clamped so it never wraps.
21const getKeyHandler =
22 ({ index, total, onIndexChange }: LightboxNavOptions) =>
23 (event: KeyboardEvent<HTMLDivElement>) => {
24 const last = total - 1;
25 const target = KEY_TARGETS[event.key]?.(index, last);
26
27 if (target !== undefined) {
28 event.preventDefault();
29 onIndexChange(Math.min(Math.max(target, 0), last));
30 }
31 };
32
33export interface GalleryLightboxProps {
34 /** The image showing */
35 image: GalleryImage;
36 /** Its position in `images[]` */
37 index: number;
38 /** How many images there are */
39 total: number;
40 labels: GalleryLabels;
41 renderImage: GalleryRenderImage;
42 onIndexChange: (index: number) => void;
43 onClose: () => void;
44 onCloseAutoFocus: (event: Event) => void;
45}
46
47/**
48 * The lightbox: a fullscreen `Modal` with a counter title, Close, the current
49 * image with its caption, and Previous and Next. It does not wrap: Previous is
50 * disabled on the first image and Next on the last. Arrow keys, Home and End
51 * navigate from anywhere in the dialog; a swipe on the image does the same.
52 */
53export const GalleryLightbox = ({
54 image,
55 index,
56 total,
57 labels,
58 renderImage,
59 onIndexChange,
60 onClose,
61 onCloseAutoFocus,
62}: GalleryLightboxProps) => {
63 const captionId = useId();
64 const handleKeyDown = getKeyHandler({ index, total, onIndexChange });
65 const title = (
66 <GalleryLightboxTitle index={index} total={total} labels={labels} />
67 );
68
69 return (
70 <Modal
71 size="fullscreen"
72 closeLabel={labels.close}
73 title={title}
74 onClose={onClose}
75 onCloseAutoFocus={onCloseAutoFocus}
76 onKeyDown={handleKeyDown}
77 aria-describedby={image.caption ? captionId : undefined}
78 >
79 <GalleryLightboxBody
80 image={image}
81 index={index}
82 total={total}
83 labels={labels}
84 renderImage={renderImage}
85 onIndexChange={onIndexChange}
86 captionId={captionId}
87 />
88 </Modal>
89 );
90};
gallery-thumbnail.tsx
1import { type Ref } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import { ImageIcon } from "@behivetech/atoms.icon";
4import {
5 type GalleryImage,
6 type GalleryLabels,
7 type GalleryRenderImage,
8} from "./gallery-types.js";
9import { rowSpan } from "./masonry.js";
10import { useImageStatus } from "./use-image-status.js";
11import styles from "./gallery-thumbnail.module.scss";
12
13export interface GalleryThumbnailProps {
14 image: GalleryImage;
15 /** Position in `images[]`, passed back to `onOpen` */
16 index: number;
17 labels: GalleryLabels;
18 renderImage: GalleryRenderImage;
19 /** The `sizes` string handed to `renderImage` */
20 sizes: string;
21 onOpen: (index: number) => void;
22 /** Lets Gallery return focus here when the lightbox closes */
23 buttonRef: Ref<HTMLButtonElement>;
24}
25
26/**
27 * One grid tile: a button named by the image alt that opens the lightbox. Its
28 * row span comes from the image's ratio, so it is the right height before the
29 * image loads.
30 */
31export const GalleryThumbnail = ({
32 image,
33 index,
34 labels,
35 renderImage,
36 sizes,
37 onOpen,
38 buttonRef,
39}: GalleryThumbnailProps) => {
40 const { ref, status, handlers } = useImageStatus(image.src);
41 const isError = status === "error";
42 const [rootClass, getChildClass] = getClassName({
43 rootClass: "BHT__GalleryThumbnail",
44 modifiers: { [`span-${rowSpan(image)}`]: true },
45 styles,
46 });
47
48 return (
49 <li className={rootClass}>
50 <button
51 ref={buttonRef}
52 type="button"
53 className={getChildClass("button")}
54 aria-haspopup="dialog"
55 // A failed tile shows its own text; the name carries it (WCAG 2.5.3).
56 aria-label={isError ? `${image.alt}, ${labels.unavailable}` : image.alt}
57 onClick={() => onOpen(index)}
58 >
59 <span ref={ref} className={getChildClass("media")} {...handlers}>
60 {isError ? (
61 <span className={getChildClass("unavailable")}>
62 <ImageIcon size="lg" />
63 {labels.unavailable}
64 </span>
65 ) : (
66 renderImage(image, { context: "thumbnail", sizes })
67 )}
68 </span>
69 </button>
70 </li>
71 );
72};
gallery-types.ts
1import { type ReactNode } from "react";
2
3/**
4 * One image in the grid and the lightbox. A tile keeps the image's ratio up to
5 * about 3:1 (height over width): the row span is capped at 150 rows, so a taller
6 * image is cropped to that height in its thumbnail (`object-fit: cover`). The
7 * lightbox always shows the whole image.
8 */
9export interface GalleryImage {
10 /** Image URL */
11 src: string;
12 /** Required: names the thumbnail button and the lightbox image */
13 alt: string;
14 /** Intrinsic width in px; with `height` it fixes the tile's shape before the image loads */
15 width: number;
16 /** Intrinsic height in px. Past about 3 times the width the thumbnail crops the image */
17 height: number;
18 /** Shown under the image in the lightbox only; never over a thumbnail */
19 caption?: string;
20}
21
22/** The breakpoints `columns` is set for: below 48rem, from 48rem, from 64rem. */
23export type GalleryBreakpoint = "xs" | "md" | "lg";
24
25/** Column counts per breakpoint: `xs` below 48rem, `md` from 48rem, `lg` from 64rem */
26export type GalleryColumnsConfig = Partial<Record<GalleryBreakpoint, number>>;
27
28/** A column count for every width, or a count per breakpoint. */
29export type GalleryColumns = number | GalleryColumnsConfig;
30
31export interface GalleryRenderImageOptions {
32 /** Where the image is drawn */
33 context: "thumbnail" | "lightbox";
34 /** A ready-made `sizes` string for that context, to pass to `next/image` */
35 sizes: string;
36}
37
38/** Draws an image; an app passes `next/image` here. */
39export type GalleryRenderImage = (
40 image: GalleryImage,
41 options: GalleryRenderImageOptions,
42) => ReactNode;
43
44/** Every text the gallery shows or announces, so an app can translate them. */
45export interface GalleryLabels {
46 /** Lightbox close button name */
47 close: string;
48 /** Lightbox previous button name */
49 previous: string;
50 /** Lightbox next button name */
51 next: string;
52 /** Visually hidden word before the counter, so the dialog reads "Image 4 of 12" */
53 image: string;
54 /** Counter text; `{current}` and `{total}` are replaced */
55 counter: string;
56 /** Spinner name while the lightbox image loads */
57 loading: string;
58 /** Text on a thumbnail whose image failed */
59 unavailable: string;
60 /** Text in the lightbox when the image failed */
61 failed: string;
62}
63
64/** What the lightbox needs to step between images. */
65export interface LightboxNavOptions {
66 /** The image showing */
67 index: number;
68 /** How many images there are */
69 total: number;
70 /** Called with the index to show next */
71 onIndexChange: (index: number) => void;
72}
73
74/** What the lightbox can step to from the image showing; it does not wrap. */
75export interface LightboxSteps {
76 /** False on the first image */
77 canGoPrevious: boolean;
78 /** False on the last image */
79 canGoNext: boolean;
80 /** Shows the previous image */
81 goPrevious: () => void;
82 /** Shows the next image */
83 goNext: () => void;
84}
gallery.tsx
1import { useMemo, type HTMLAttributes } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import { GalleryActiveLightbox } from "./gallery-active-lightbox.js";
4import { GalleryGrid } from "./gallery-grid.js";
5import { DEFAULT_LABELS } from "./gallery-labels.js";
6import {
7 type GalleryColumns,
8 type GalleryImage,
9 type GalleryLabels,
10 type GalleryRenderImage,
11} from "./gallery-types.js";
12import { DEFAULT_COLUMNS, resolveColumns } from "./masonry.js";
13import { useLightbox } from "./use-lightbox.js";
14import styles from "./gallery.module.scss";
15
16export interface GalleryProps extends Omit<
17 HTMLAttributes<HTMLDivElement>,
18 "children"
19> {
20 /** Additional class names to merge with the component root element */
21 className?: string;
22 /** The images, in the order the grid, Tab and the lightbox follow. An empty array renders nothing */
23 images: GalleryImage[];
24 /**
25 * Columns: a number for every width, or `{ xs, md, lg }` for below 48rem, from
26 * 48rem and from 64rem (any you omit keep the default). Up to 8. Defaults to
27 * `{ xs: 2, md: 3, lg: 4 }`.
28 */
29 columns?: GalleryColumns;
30 /**
31 * Draws each image, so an app can pass `next/image`. Receives the image and
32 * `{ context: "thumbnail" | "lightbox", sizes }`. Defaults to a lazy `<img>`.
33 * Render a plain `<img>` (or a component that renders one): the grid fills
34 * the tile with it.
35 */
36 renderImage?: GalleryRenderImage;
37 /** Called with `true` and the opened index when the lightbox opens, and with `false` and the index showing when it closes */
38 onOpenChange?: (open: boolean, index: number) => void;
39 /** Replaces any of the built-in English texts, for translation */
40 labels?: Partial<GalleryLabels>;
41}
42
43// One root modifier per breakpoint (`xs-2`, `md-3`, `lg-4`), which the
44// stylesheet turns into the grid template.
45const columnModifiers = (counts: Record<string, number>) =>
46 Object.fromEntries(
47 Object.entries(counts).map(([breakpoint, count]) => [
48 `${breakpoint}-${count}`,
49 true,
50 ]),
51 );
52
53const defaultRenderImage: GalleryRenderImage = (image, { context }) => (
54 <img
55 src={image.src}
56 alt={image.alt}
57 width={image.width}
58 height={image.height}
59 loading={context === "thumbnail" ? "lazy" : "eager"}
60 decoding="async"
61 />
62);
63
64/**
65 * A responsive masonry image grid that opens a swipeable, keyboard-navigable
66 * lightbox. It is one list in `images[]` order, so Tab, reading and lightbox
67 * order agree; tiles keep each image's ratio and captions show in the lightbox
68 * only. Built on `Modal`.
69 */
70export const Gallery = ({
71 className,
72 images,
73 columns = DEFAULT_COLUMNS,
74 renderImage = defaultRenderImage,
75 onOpenChange,
76 labels,
77 ...rest
78}: GalleryProps) => {
79 const counts = resolveColumns(columns);
80 const [rootClass] = getClassName({
81 className,
82 rootClass: "BHT__Gallery",
83 modifiers: columnModifiers(counts),
84 styles,
85 });
86 const lightbox = useLightbox({ onOpenChange });
87 const mergedLabels = useMemo(
88 () => ({ ...DEFAULT_LABELS, ...labels }),
89 [labels],
90 );
91 const parts = { images, labels: mergedLabels, renderImage, lightbox };
92
93 return images.length > 0 ? (
94 <div {...rest} className={rootClass}>
95 <GalleryGrid {...parts} columns={counts} />
96 <GalleryActiveLightbox {...parts} />
97 </div>
98 ) : null;
99};
masonry.ts
1import {
2 type GalleryBreakpoint,
3 type GalleryColumns,
4 type GalleryImage,
5} from "./gallery-types.js";
6
7/** Columns per breakpoint when `columns` is not given. */
8export const DEFAULT_COLUMNS: Record<GalleryBreakpoint, number> = {
9 xs: 2,
10 md: 3,
11 lg: 4,
12};
13
14/** Most columns the stylesheet has a class for. */
15export const MAX_COLUMNS = 8;
16
17/**
18 * Grid rows per column width (gallery.module.scss derives the row height from
19 * it). More rows per column means a tile's height is closer to its image's
20 * ratio, at the price of a bigger span number.
21 */
22export const ROWS_PER_COLUMN = 48;
23
24/**
25 * Rows added to every span to cover the vertical gap (a margin on the tile) at
26 * typical column widths, so tile height stays within a few px of the ratio.
27 */
28export const GAP_ROWS = 2.4;
29
30/** Tallest and flattest tile the stylesheet has a span class for. */
31export const MAX_SPAN = 150;
32
33/**
34 * Resolves the `columns` prop to a whole count per breakpoint. A number applies
35 * everywhere; an object overrides the defaults per breakpoint.
36 */
37export const resolveColumns = (
38 columns: GalleryColumns,
39): Record<GalleryBreakpoint, number> => {
40 const requested =
41 typeof columns === "number"
42 ? { xs: columns, md: columns, lg: columns }
43 : { ...DEFAULT_COLUMNS, ...columns };
44
45 return Object.fromEntries(
46 Object.entries(requested).map(([breakpoint, count]) => [
47 breakpoint,
48 Math.min(MAX_COLUMNS, Math.max(1, Math.floor(count) || 1)),
49 ]),
50 ) as Record<GalleryBreakpoint, number>;
51};
52
53/**
54 * How many grid rows a tile spans, from its image's height over width. Tiles of
55 * different ratios then stack into columns with the grid's own auto-placement,
56 * while the DOM stays in `images[]` order.
57 */
58export const rowSpan = ({ width, height }: GalleryImage): number =>
59 Math.min(
60 MAX_SPAN,
61 Math.max(1, Math.round((height / width) * ROWS_PER_COLUMN + GAP_ROWS)),
62 );
63
64/**
65 * The `sizes` string for a thumbnail, from the columns it will sit in (the
66 * gutter is ignored, so it errs a little large).
67 */
68export const thumbnailSizes = (
69 columns: Record<GalleryBreakpoint, number>,
70): string => {
71 const share = (count: number) => `${Math.round(100 / count)}vw`;
72
73 return [
74 `(min-width: 64rem) ${share(columns.lg)}`,
75 `(min-width: 48rem) ${share(columns.md)}`,
76 share(columns.xs),
77 ].join(", ");
78};
use-image-status.ts
1import {
2 useEffect,
3 useRef,
4 useState,
5 type ReactEventHandler,
6 type RefObject,
7} from "react";
8
9/** Where an image is: still loading, shown, or failed. */
10export type ImageStatus = "loading" | "loaded" | "error";
11
12interface ImageStatusResult {
13 /** Attach to the element that contains the rendered `<img>` */
14 ref: RefObject<HTMLDivElement | null>;
15 status: ImageStatus;
16 /** Spread on the same element: load and error do not bubble, so React's capture phase catches them */
17 handlers: {
18 onLoadCapture: ReactEventHandler;
19 onErrorCapture: ReactEventHandler;
20 };
21}
22
23/**
24 * Tracks whether the `<img>` inside a wrapper has loaded or failed, whatever
25 * component drew it (the default `<img>` or an app's `next/image`). `resetKey`
26 * names the image: a new key starts again at `loading`. An image that finished
27 * before hydration fires no event, so a new key also reads `complete`.
28 */
29export const useImageStatus = (resetKey: string): ImageStatusResult => {
30 const ref = useRef<HTMLDivElement | null>(null);
31 const [result, setResult] = useState<{ key: string; status: ImageStatus }>({
32 key: resetKey,
33 status: "loading",
34 });
35 const status = result.key === resetKey ? result.status : "loading";
36
37 useEffect(() => {
38 const img = ref.current?.querySelector("img");
39
40 // An image that settled before React attached fires no event later, so a
41 // finished image with no pixels (a 404 under SSR) is an error now.
42 if (img?.complete) {
43 setResult({
44 key: resetKey,
45 status: img.naturalWidth > 0 ? "loaded" : "error",
46 });
47 }
48 }, [resetKey]);
49
50 return {
51 ref,
52 status,
53 handlers: {
54 onLoadCapture: () => setResult({ key: resetKey, status: "loaded" }),
55 onErrorCapture: () => setResult({ key: resetKey, status: "error" }),
56 },
57 };
58};
use-lightbox.ts
1import { useRef, useState } from "react";
2
3interface UseLightboxOptions {
4 /** Called with `true` and the opened index, and with `false` and the index showing when it closes */
5 onOpenChange?: (open: boolean, index: number) => void;
6}
7
8/**
9 * Open state for the gallery lightbox, and where focus goes when it closes: the
10 * thumbnail of the image showing at that moment, not the one first opened,
11 * scrolled into view.
12 */
13export const useLightbox = ({ onOpenChange }: UseLightboxOptions) => {
14 const [openIndex, setOpenIndex] = useState<number | null>(null);
15 const thumbnails = useRef<(HTMLButtonElement | null)[]>([]);
16 // The image showing, kept outside state so the close handlers read the latest.
17 const shownIndex = useRef(0);
18
19 const open = (index: number) => {
20 shownIndex.current = index;
21 setOpenIndex(index);
22 onOpenChange?.(true, index);
23 };
24
25 const changeIndex = (index: number) => {
26 shownIndex.current = index;
27 setOpenIndex(index);
28 };
29
30 const close = () => {
31 setOpenIndex(null);
32 onOpenChange?.(false, shownIndex.current);
33 };
34
35 const restoreFocus = (event: Event) => {
36 const thumbnail = thumbnails.current[shownIndex.current];
37
38 event.preventDefault();
39 thumbnail?.focus();
40 thumbnail?.scrollIntoView?.({ block: "nearest" });
41 };
42
43 const registerThumbnail =
44 (index: number) => (element: HTMLButtonElement | null) => {
45 thumbnails.current[index] = element;
46 };
47
48 return {
49 openIndex,
50 open,
51 changeIndex,
52 close,
53 restoreFocus,
54 registerThumbnail,
55 };
56};
57
58/** What `useLightbox` returns, for the components that read or drive it. */
59export type LightboxState = ReturnType<typeof useLightbox>;
use-stranded-focus.ts
1import { useEffect, type RefObject } from "react";
2
3/**
4 * A disabled button cannot keep focus, so when Previous or Next becomes disabled
5 * while holding it (or focus fell to the page), hand it to the other arrow.
6 * `rootRef` holds exactly those two buttons, in that order; Close sits outside it.
7 */
8export const useStrandedFocus = (
9 rootRef: RefObject<HTMLElement | null>,
10 canGoPrevious: boolean,
11 canGoNext: boolean,
12) => {
13 useEffect(() => {
14 const [previous, next] = Array.from(
15 rootRef.current?.querySelectorAll("button") ?? [],
16 );
17 const active = document.activeElement;
18 const focusLost = active === document.body || active === null;
19 const target = canGoPrevious ? previous : next;
20 const stranded =
21 (!canGoPrevious && (active === previous || focusLost)) ||
22 (!canGoNext && (active === next || focusLost));
23
24 if (stranded && target && !target.disabled) {
25 target.focus();
26 }
27 }, [rootRef, canGoPrevious, canGoNext]);
28};
use-swipe.ts
1import { useRef, type PointerEvent, type RefObject } from "react";
2
3/** Horizontal travel that commits a swipe on its own */
4export const SWIPE_DISTANCE_PX = 50;
5/** A faster flick commits even when it is short */
6export const SWIPE_VELOCITY_PX_PER_MS = 0.3;
7/** Past the first or last image the drag follows the finger at this fraction */
8export const RUBBER_BAND = 0.3;
9/** The custom property the stylesheet reads to offset the image */
10export const DRAG_PROPERTY = "--gallery-drag";
11/** Set to `none` while a finger is down so the image is not eased behind it */
12export const DRAG_TRANSITION_PROPERTY = "--gallery-drag-transition";
13
14interface UseSwipeOptions {
15 /** Whether there is an image to go to on each side */
16 canGoPrevious: boolean;
17 canGoNext: boolean;
18 onPrevious: () => void;
19 onNext: () => void;
20}
21
22interface Gesture {
23 startX: number;
24 startY: number;
25 startTime: number;
26 pointerId: number;
27}
28
29type SwipeEvent = PointerEvent<HTMLElement>;
30
31/**
32 * Touch swipe with pointer events and no dependency. The image follows the
33 * finger by writing one custom property on the tracked element (re-rendering on
34 * every move would be wasteful); the stylesheet turns it into a transform. A
35 * swipe left goes next, right goes previous; at either end the drag rubber-bands
36 * and snaps back.
37 */
38export const useSwipe = ({
39 canGoPrevious,
40 canGoNext,
41 onPrevious,
42 onNext,
43}: UseSwipeOptions) => {
44 const trackRef: RefObject<HTMLDivElement | null> = useRef(null);
45 const gesture = useRef<Gesture | null>(null);
46
47 const setDrag = (offsetPx: number) => {
48 const style = trackRef.current?.style;
49
50 style?.setProperty(DRAG_PROPERTY, `${offsetPx}px`);
51 if (offsetPx === 0) {
52 style?.removeProperty(DRAG_TRANSITION_PROPERTY);
53 } else {
54 style?.setProperty(DRAG_TRANSITION_PROPERTY, "none");
55 }
56 };
57
58 // Stop the hit-testing side effects of a finished or cancelled gesture.
59 const finish = (event: SwipeEvent) => {
60 if (gesture.current) {
61 event.currentTarget.releasePointerCapture?.(gesture.current.pointerId);
62 }
63 gesture.current = null;
64 setDrag(0);
65 };
66
67 // Only the first finger, or the main mouse button, starts a gesture; a second
68 // finger or another pointer id never restarts or ends it.
69 const isTracked = (event: SwipeEvent) =>
70 gesture.current?.pointerId === event.pointerId;
71
72 const onPointerDown = (event: SwipeEvent) => {
73 if (event.isPrimary && event.button === 0) {
74 gesture.current = {
75 startX: event.clientX,
76 startY: event.clientY,
77 startTime: performance.now(),
78 pointerId: event.pointerId,
79 };
80 event.currentTarget.setPointerCapture?.(event.pointerId);
81 }
82 };
83
84 const onPointerMove = (event: SwipeEvent) => {
85 const start = gesture.current;
86
87 if (start && isTracked(event)) {
88 const dx = event.clientX - start.startX;
89 const dy = event.clientY - start.startY;
90 const blocked = dx < 0 ? !canGoNext : !canGoPrevious;
91
92 if (Math.abs(dx) > Math.abs(dy)) {
93 setDrag(blocked ? dx * RUBBER_BAND : dx);
94 }
95 }
96 };
97
98 const onPointerUp = (event: SwipeEvent) => {
99 const start = gesture.current;
100
101 if (start && isTracked(event)) {
102 const dx = event.clientX - start.startX;
103 const dy = event.clientY - start.startY;
104 const elapsed = Math.max(performance.now() - start.startTime, 1);
105 const isHorizontal = Math.abs(dx) > Math.abs(dy);
106 const commits =
107 isHorizontal &&
108 (Math.abs(dx) > SWIPE_DISTANCE_PX ||
109 Math.abs(dx) / elapsed > SWIPE_VELOCITY_PX_PER_MS);
110
111 finish(event);
112
113 if (commits && dx < 0 && canGoNext) {
114 onNext();
115 } else if (commits && dx > 0 && canGoPrevious) {
116 onPrevious();
117 }
118 }
119 };
120
121 return {
122 trackRef,
123 handlers: {
124 onPointerDown,
125 onPointerMove,
126 onPointerUp,
127 onPointerCancel: (event: SwipeEvent) => {
128 if (isTracked(event)) {
129 finish(event);
130 }
131 },
132 },
133 };
134};
gallery-lightbox-stage.module.scss
1// The lightbox's image area. use-swipe.ts writes --gallery-drag (the finger's
2// offset) and, while a finger is down, --gallery-drag-transition: none, so the
3// image is not eased behind it.
4.BHT__GalleryLightboxStage {
5 position: relative;
6 grid-area: stage;
7 min-width: 0;
8 min-height: 0;
9 overflow: hidden;
10 // pan-y leaves vertical scrolling to the browser and sends horizontal
11 // movement to our pointer handlers.
12 touch-action: pan-y;
13 user-select: none;
14
15 &__track {
16 position: relative;
17 width: 100%;
18 height: 100%;
19 transform: translateX(var(--gallery-drag, 0));
20 transition: var(
21 --gallery-drag-transition,
22 transform var(--md-sys-motion-duration-short-4, 200ms)
23 var(--md-sys-motion-easing-standard, ease)
24 );
25
26 @media (prefers-reduced-motion: reduce) {
27 transition: none;
28 }
29 }
30
31 &__image {
32 position: relative;
33 width: 100%;
34 height: 100%;
35
36 // A native image drag would end the pointer gesture; let the stage have it.
37 img {
38 position: relative;
39 display: block;
40 width: 100%;
41 height: 100%;
42 border-radius: var(--md-sys-shape-corner-small, 8px);
43 object-fit: contain;
44 pointer-events: none;
45 }
46 }
47
48 // Same box and contain sizing as the image, drawn behind it.
49 &__placeholder {
50 position: absolute;
51 inset: 0;
52 display: block;
53 width: 100%;
54 height: 100%;
55 }
56
57 &__placeholder-fill {
58 fill: var(--md-sys-color-surface-container-high);
59 }
60
61 &__spinner {
62 position: absolute;
63 top: 50%;
64 left: 50%;
65 transform: translate(-50%, -50%);
66 }
67
68 &__failed {
69 display: flex;
70 flex-direction: column;
71 align-items: center;
72 justify-content: center;
73 gap: var(--spacing-2);
74 height: 100%;
75 margin: 0;
76 color: var(--md-sys-color-on-surface-variant);
77 font-family: var(--md-sys-typescale-body-large-font, inherit);
78 font-size: var(--md-sys-typescale-body-large-size, 1rem);
79 line-height: var(--md-sys-typescale-body-large-line-height, 1.5rem);
80 text-align: center;
81 }
82}
gallery-lightbox.module.scss
1@use "@behivetech/cms.base-styles/breakpoints.scss" as breakpoints;
2
3// Below md the controls sit in a bottom bar under the image (thumb reach); from
4// md they sit in the side gutters. They never overlap the photo, so their
5// contrast does not depend on it. Source order is Previous, image, Next, caption,
6// which is also the Tab order after Close.
7.BHT__GalleryLightbox {
8 display: grid;
9 flex: 1;
10 grid-template-areas:
11 "stage stage"
12 "caption caption"
13 "previous next";
14 grid-template-columns: 1fr 1fr;
15 grid-template-rows: minmax(0, 1fr) auto auto;
16 gap: var(--spacing-4);
17 min-height: 0;
18 padding: 0 var(--spacing-4) var(--spacing-6);
19 color: var(--md-sys-color-on-surface);
20
21 @include breakpoints.md {
22 grid-template-areas:
23 "previous stage next"
24 "caption caption caption";
25 grid-template-columns: auto minmax(0, 1fr) auto;
26 grid-template-rows: minmax(0, 1fr) auto;
27 padding-inline: var(--spacing-6);
28 }
29
30 &__previous {
31 grid-area: previous;
32 justify-self: start;
33 align-self: center;
34 }
35
36 &__next {
37 grid-area: next;
38 justify-self: end;
39 align-self: center;
40 }
41
42 &__caption {
43 grid-area: caption;
44 justify-self: center;
45 max-width: 45rem;
46 margin: 0;
47 font-family: var(--md-sys-typescale-body-large-font, inherit);
48 font-size: var(--md-sys-typescale-body-large-size, 1rem);
49 line-height: var(--md-sys-typescale-body-large-line-height, 1.5rem);
50 text-align: center;
51 }
52
53 &__visually-hidden {
54 // Fixed px: the standard clip idiom for hiding text from sight while
55 // leaving it to screen readers, not a size that should scale.
56 position: absolute;
57 width: 1px;
58 height: 1px;
59 padding: 0;
60 margin: -1px;
61 overflow: hidden;
62 clip: rect(0, 0, 0, 0);
63 white-space: nowrap;
64 border: 0;
65 }
66}
gallery-thumbnail.module.scss
1// One grid tile. Its height is a row span from the image's ratio (a --span-N
2// modifier, N matching masonry.ts), so it is the right height before the image
3// loads; the image fills it with object-fit: cover, which crops only the few px
4// of rounding. The bottom margin is the vertical gap.
5$max-span: 150;
6
7.BHT__GalleryThumbnail {
8 margin: 0 0 var(--gallery-gap);
9 padding: 0;
10 list-style: none;
11
12 @for $n from 1 through $max-span {
13 &--span-#{$n} {
14 grid-row-end: span #{$n};
15 }
16 }
17
18 &__button {
19 position: relative;
20 display: block;
21 width: 100%;
22 height: 100%;
23 margin: 0;
24 padding: 0;
25 border: 0;
26 border-radius: var(--md-sys-shape-corner-medium, 12px);
27 background: none;
28 color: inherit;
29 cursor: pointer;
30 overflow: hidden;
31 transition: box-shadow var(--md-sys-motion-duration-short-4, 200ms)
32 var(--md-sys-motion-easing-standard, ease);
33
34 // State layer: on-surface at the hover opacity, over the image.
35 &::after {
36 content: "";
37 position: absolute;
38 inset: 0;
39 background: var(--md-sys-color-on-surface);
40 opacity: 0;
41 pointer-events: none;
42 }
43
44 // The glow is on the tile (an object), never on text. The token is transparent in Light.
45 &:hover {
46 box-shadow: 0 0 24px var(--brand-glow-violet, transparent);
47
48 &::after {
49 opacity: var(--md-sys-state-hover-opacity, 0.08);
50 }
51 }
52
53 &:focus-visible {
54 outline: 3px solid var(--md-sys-color-secondary);
55 outline-offset: 2px;
56 }
57 }
58
59 &__media {
60 position: relative;
61 display: block;
62 height: 100%;
63 background: var(--md-sys-color-surface-container-high);
64
65 img {
66 position: absolute;
67 inset: 0;
68 width: 100%;
69 height: 100%;
70 object-fit: cover;
71 }
72 }
73
74 &__unavailable {
75 position: absolute;
76 inset: 0;
77 display: flex;
78 flex-direction: column;
79 align-items: center;
80 justify-content: center;
81 gap: var(--spacing-2);
82 background: var(--md-sys-color-surface-container);
83 color: var(--md-sys-color-on-surface-variant);
84 font-family: var(--md-sys-typescale-body-medium-font, inherit);
85 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);
86 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);
87 text-align: center;
88 }
89}
gallery.module.scss
1@use "@behivetech/cms.base-styles/breakpoints.scss" as breakpoints;
2
3// Masonry from one CSS grid, so the list stays in images[] order. The root is
4// an inline-size container so the list can size its rows from the container's
5// width (100cqi): each row is 1/48 of a column's width, a tile spans the rows
6// its image's ratio needs (see GalleryThumbnail), and the grid's own sparse
7// auto-placement drops each tile into the column that frees up first. The
8// vertical gap is a margin on the tile, not row-gap, so it never rounds into
9// the spans. The column count is a modifier per breakpoint (--xs-2, --md-3,
10// --lg-4 ...) that sets --cols; no inline style is involved.
11$max-columns: 8;
12$rows-per-column: 48;
13
14.BHT__Gallery {
15 --gallery-gap: var(--spacing-2);
16 --cols: 2;
17
18 container-type: inline-size;
19 color: var(--md-sys-color-on-surface);
20
21 @include breakpoints.md {
22 --gallery-gap: var(--spacing-4);
23 }
24
25 @for $n from 1 through $max-columns {
26 &--xs-#{$n} {
27 --cols: #{$n};
28 }
29 }
30
31 @include breakpoints.md {
32 @for $n from 1 through $max-columns {
33 &--md-#{$n} {
34 --cols: #{$n};
35 }
36 }
37 }
38
39 @include breakpoints.lg {
40 @for $n from 1 through $max-columns {
41 &--lg-#{$n} {
42 --cols: #{$n};
43 }
44 }
45 }
46
47 &__list {
48 display: grid;
49 grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
50 grid-auto-rows: calc(
51 (100cqi - (var(--cols) - 1) * var(--gallery-gap)) / var(--cols) /
52 #{$rows-per-column}
53 );
54 column-gap: var(--gallery-gap);
55 // The last row's tiles carry a bottom margin; pull it back out.
56 margin: 0 0 calc(-1 * var(--gallery-gap));
57 padding: 0;
58 list-style: none;
59 }
60}
index.ts
1export { Gallery } from "./gallery.js";
2export type { GalleryProps } from "./gallery.js";
3export type {
4 GalleryColumns,
5 GalleryColumnsConfig,
6 GalleryImage,
7 GalleryLabels,
8 GalleryRenderImage,
9 GalleryRenderImageOptions,
10} from "./gallery-types.js";
gallery.example.tsx
1import { Gallery, type GalleryImage } from "./gallery.js";
2
3// Inline SVG stand-ins so the examples need no network.
4const placeholder = (hue: number, width: number, height: number) =>
5 `data:image/svg+xml;utf8,${encodeURIComponent(
6 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="hsl(${hue} 80% 55%)"/></svg>`,
7 )}`;
8
9const SIZES = [
10 [1200, 800],
11 [800, 1200],
12 [1000, 1000],
13 [1200, 800],
14 [800, 1000],
15 [1200, 900],
16 [900, 1200],
17 [1200, 800],
18] as const;
19
20const IMAGES: GalleryImage[] = SIZES.map(([width, height], index) => ({
21 src: placeholder(index * 45, width, height),
22 alt: `Photo ${index + 1}`,
23 width,
24 height,
25 caption: index % 2 === 0 ? `Caption for photo ${index + 1}` : undefined,
26}));
27
28/** The default grid: two columns on a phone, three from 48rem and four from 64rem. Tap a tile to open the lightbox. */
29export const BasicGallery = () => <Gallery images={IMAGES} />;
30
31/** A fixed number of columns at every width. */
32export const ThreeColumns = () => <Gallery images={IMAGES} columns={3} />;
33
34/** Columns per breakpoint: `xs` below 48rem, `md` from 48rem, `lg` from 64rem. */
35export const ResponsiveColumns = () => (
36 <Gallery images={IMAGES} columns={{ xs: 1, md: 2, lg: 5 }} />
37);
gallery-lightbox.spec.tsx
1import { fireEvent, screen, within } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import {
4 getDialog,
5 renderGallery,
6 stubImageLoading,
7} from "./test-support/gallery-fixtures.js";
8
9stubImageLoading();
10
11describe("Gallery lightbox", () => {
12 const open = async (name = "Living room") => {
13 await userEvent.click(screen.getByRole("button", { name }));
14 };
15
16 it("opens a dialog named by position, with the image and caption", async () => {
17 renderGallery();
18 await open();
19 const dialog = getDialog();
20 expect(dialog).toHaveAccessibleName("Image 1 of 4");
21 expect(dialog).toHaveAccessibleDescription("Living room with the mural");
22 expect(
23 within(dialog).getByRole("img", { name: "Living room" }),
24 ).toBeInTheDocument();
25 });
26
27 it("opens at the tapped image", async () => {
28 renderGallery();
29 await open("Garden");
30 expect(getDialog()).toHaveAccessibleName("Image 3 of 4");
31 });
32
33 it("names the Close, Previous and Next buttons", async () => {
34 renderGallery();
35 await open("Kitchen");
36 expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
37 expect(
38 screen.getByRole("button", { name: "Previous image" }),
39 ).toBeInTheDocument();
40 expect(
41 screen.getByRole("button", { name: "Next image" }),
42 ).toBeInTheDocument();
43 });
44
45 it("announces each change through a polite live region", async () => {
46 renderGallery();
47 await open();
48 expect(getDialog().querySelector('[aria-live="polite"]')).toHaveTextContent(
49 "Image 1 of 4",
50 );
51 });
52
53 it("starts with focus on Close", async () => {
54 renderGallery();
55 await open();
56 expect(screen.getByRole("button", { name: "Close" })).toHaveFocus();
57 });
58
59 it("goes to the next image on ArrowRight and back on ArrowLeft", async () => {
60 renderGallery();
61 await open();
62 await userEvent.keyboard("{ArrowRight}");
63 expect(getDialog()).toHaveAccessibleName("Image 2 of 4");
64 await userEvent.keyboard("{ArrowLeft}");
65 expect(getDialog()).toHaveAccessibleName("Image 1 of 4");
66 });
67
68 it("jumps to the ends on Home and End", async () => {
69 renderGallery();
70 await open("Kitchen");
71 await userEvent.keyboard("{End}");
72 expect(getDialog()).toHaveAccessibleName("Image 4 of 4");
73 await userEvent.keyboard("{Home}");
74 expect(getDialog()).toHaveAccessibleName("Image 1 of 4");
75 });
76
77 it("does not wrap: Previous is disabled on the first image and Next on the last", async () => {
78 renderGallery();
79 await open();
80 expect(
81 screen.getByRole("button", { name: "Previous image" }),
82 ).toBeDisabled();
83 await userEvent.keyboard("{ArrowLeft}");
84 expect(getDialog()).toHaveAccessibleName("Image 1 of 4");
85
86 await userEvent.keyboard("{End}");
87 expect(screen.getByRole("button", { name: "Next image" })).toBeDisabled();
88 await userEvent.keyboard("{ArrowRight}");
89 expect(getDialog()).toHaveAccessibleName("Image 4 of 4");
90 });
91
92 it("moves focus to the other arrow when the focused one becomes disabled", async () => {
93 renderGallery();
94 await open("Kitchen");
95 const previous = screen.getByRole("button", { name: "Previous image" });
96 previous.focus();
97 await userEvent.keyboard("{Enter}");
98 expect(getDialog()).toHaveAccessibleName("Image 1 of 4");
99 expect(screen.getByRole("button", { name: "Next image" })).toHaveFocus();
100 });
101
102 it("closes on Escape and returns focus to the thumbnail", async () => {
103 renderGallery();
104 await open("Kitchen");
105 await userEvent.keyboard("{Escape}");
106 expect(screen.queryByRole("dialog")).toBeNull();
107 expect(screen.getByRole("button", { name: "Kitchen" })).toHaveFocus();
108 });
109
110 it("returns focus to the thumbnail showing at close, not the one opened", async () => {
111 renderGallery();
112 await open("Living room");
113 await userEvent.keyboard("{ArrowRight}{ArrowRight}");
114 await userEvent.click(screen.getByRole("button", { name: "Close" }));
115 expect(screen.getByRole("button", { name: "Garden" })).toHaveFocus();
116 });
117
118 it("reports open and close with the index through onOpenChange", async () => {
119 const onOpenChange = vi.fn();
120 renderGallery({ onOpenChange });
121 await open("Kitchen");
122 expect(onOpenChange).toHaveBeenLastCalledWith(true, 1);
123 await userEvent.keyboard("{ArrowRight}{Escape}");
124 expect(onOpenChange).toHaveBeenLastCalledWith(false, 2);
125 });
126
127 it("shows a spinner and aria-busy until the image loads", async () => {
128 renderGallery();
129 await open();
130 const dialog = getDialog();
131 expect(
132 within(dialog).getByRole("status", { name: "Loading image" }),
133 ).toBeInTheDocument();
134 expect(dialog.querySelector('[aria-busy="true"]')).not.toBeNull();
135
136 fireEvent.load(within(dialog).getByRole("img", { name: "Living room" }));
137 expect(within(dialog).queryByRole("status")).toBeNull();
138 expect(dialog.querySelector('[aria-busy="true"]')).toBeNull();
139 });
140
141 it("never leaves the previous image on screen while the next loads", async () => {
142 renderGallery();
143 await open();
144 const first = within(getDialog()).getByRole("img", { name: "Living room" });
145 fireEvent.load(first);
146
147 await userEvent.keyboard("{ArrowRight}");
148 const dialog = getDialog();
149 const second = within(dialog).getByRole("img", { name: "Kitchen" });
150 expect(second).not.toBe(first);
151 expect(first.isConnected).toBe(false);
152 // A placeholder of the new image's own shape stands in until it loads.
153 expect(
154 dialog.querySelector("svg[class*=placeholder]")?.getAttribute("viewBox"),
155 ).toBe("0 0 800 1200");
156
157 fireEvent.load(second);
158 expect(dialog.querySelector("svg[class*=placeholder]")).toBeNull();
159 });
160
161 it("explains a failed image and keeps navigation working", async () => {
162 renderGallery();
163 await open();
164 fireEvent.error(
165 within(getDialog()).getByRole("img", { name: "Living room" }),
166 );
167 expect(
168 screen.getByText("This image could not be loaded."),
169 ).toBeInTheDocument();
170 await userEvent.keyboard("{ArrowRight}");
171 expect(getDialog()).toHaveAccessibleName("Image 2 of 4");
172 });
173
174 it("takes translated labels", async () => {
175 renderGallery({
176 labels: {
177 close: "Cerrar",
178 counter: "{current} de {total}",
179 image: "Imagen",
180 },
181 });
182 await open();
183 expect(getDialog()).toHaveAccessibleName("Imagen 1 de 4");
184 expect(screen.getByRole("button", { name: "Cerrar" })).toBeInTheDocument();
185 });
186});
gallery-swipe.spec.tsx
1import { fireEvent, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import {
4 getDialog,
5 renderGallery,
6 stubImageLoading,
7} from "./test-support/gallery-fixtures.js";
8
9stubImageLoading();
10
11describe("Gallery swipe", () => {
12 // The hook times a gesture with performance.now(), which a spec can drive.
13 const swipe = (dx: number, { dy = 0, ms = 10, pointerId = 1 } = {}) => {
14 const stage = getDialog().querySelector("[aria-busy]") as HTMLElement;
15 const now = vi.spyOn(performance, "now").mockReturnValueOnce(0);
16 const down = { clientX: 200, clientY: 200, pointerId, isPrimary: true };
17 const end = { ...down, clientX: 200 + dx, clientY: 200 + dy };
18
19 fireEvent.pointerDown(stage, down);
20 now.mockReturnValue(ms);
21 fireEvent.pointerMove(stage, end);
22 fireEvent.pointerUp(stage, end);
23 now.mockRestore();
24 };
25
26 const openAt = async (name: string) => {
27 renderGallery();
28 await userEvent.click(screen.getByRole("button", { name }));
29 };
30
31 it("goes to the next image on a left swipe past 50px", async () => {
32 await openAt("Kitchen");
33 swipe(-80, { ms: 1000 });
34 expect(getDialog()).toHaveAccessibleName("Image 3 of 4");
35 });
36
37 it("goes to the previous image on a right swipe past 50px", async () => {
38 await openAt("Kitchen");
39 swipe(80, { ms: 1000 });
40 expect(getDialog()).toHaveAccessibleName("Image 1 of 4");
41 });
42
43 it("does not move on a short, slow drag", async () => {
44 await openAt("Kitchen");
45 swipe(-30, { ms: 1000 });
46 expect(getDialog()).toHaveAccessibleName("Image 2 of 4");
47 });
48
49 it("moves on a short, fast flick", async () => {
50 await openAt("Kitchen");
51 swipe(-30, { ms: 50 });
52 expect(getDialog()).toHaveAccessibleName("Image 3 of 4");
53 });
54
55 it("ignores a mostly vertical drag", async () => {
56 await openAt("Kitchen");
57 swipe(-80, { dy: 120, ms: 1000 });
58 expect(getDialog()).toHaveAccessibleName("Image 2 of 4");
59 });
60
61 it("does not move past the first or last image", async () => {
62 await openAt("Living room");
63 swipe(80, { ms: 1000 });
64 expect(getDialog()).toHaveAccessibleName("Image 1 of 4");
65 });
66
67 it("ignores a pointer that is not the primary one", async () => {
68 await openAt("Kitchen");
69 const stage = getDialog().querySelector("[aria-busy]") as HTMLElement;
70 fireEvent.pointerDown(stage, {
71 clientX: 200,
72 pointerId: 2,
73 isPrimary: false,
74 });
75 fireEvent.pointerUp(stage, {
76 clientX: 100,
77 pointerId: 2,
78 isPrimary: false,
79 });
80 expect(getDialog()).toHaveAccessibleName("Image 2 of 4");
81 });
82});
gallery.spec.tsx
1import { fireEvent, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { resolveColumns, rowSpan } from "./masonry.js";
4import {
5 getDialog,
6 IMAGES,
7 renderGallery,
8 stubImageLoading,
9} from "./test-support/gallery-fixtures.js";
10
11stubImageLoading();
12
13describe("Gallery grid", () => {
14 it("renders a button per image, named by its alt text", () => {
15 renderGallery();
16 IMAGES.forEach(({ alt }) => {
17 expect(screen.getByRole("button", { name: alt })).toHaveAttribute(
18 "aria-haspopup",
19 "dialog",
20 );
21 });
22 });
23
24 it("renders nothing when there are no images", () => {
25 const { container } = renderGallery({ images: [] });
26 expect(container).toBeEmptyDOMElement();
27 });
28
29 it("is one list with a button per image, in images[] order", async () => {
30 renderGallery();
31 expect(screen.getAllByRole("list")).toHaveLength(1);
32 await userEvent.tab();
33 expect(screen.getByRole("button", { name: "Living room" })).toHaveFocus();
34 await userEvent.tab();
35 expect(screen.getByRole("button", { name: "Kitchen" })).toHaveFocus();
36 await userEvent.tab();
37 expect(screen.getByRole("button", { name: "Garden" })).toHaveFocus();
38 });
39
40 it("defaults to 2 columns, 3 from md and 4 from lg", () => {
41 const { container } = renderGallery();
42 const root = container.firstElementChild as HTMLElement;
43 expect(root.className).toContain("--xs-2");
44 expect(root.className).toContain("--md-3");
45 expect(root.className).toContain("--lg-4");
46 });
47
48 it("takes a number of columns for every width", () => {
49 const { container } = renderGallery({ columns: 3 });
50 const root = container.firstElementChild as HTMLElement;
51 expect(root.className).toContain("--xs-3");
52 expect(root.className).toContain("--md-3");
53 expect(root.className).toContain("--lg-3");
54 });
55
56 it("takes a breakpoint object and keeps the default for any key left out", () => {
57 const { container } = renderGallery({ columns: { lg: 6 } });
58 const root = container.firstElementChild as HTMLElement;
59 expect(root.className).toContain("--xs-2");
60 expect(root.className).toContain("--md-3");
61 expect(root.className).toContain("--lg-6");
62 });
63
64 it("gives each tile a row span from its ratio, so tall images span more rows", () => {
65 renderGallery();
66 const spanOf = (name: string) =>
67 Number(
68 /--span-(\d+)/.exec(
69 screen.getByRole("button", { name }).closest("li")?.className ?? "",
70 )?.[1],
71 );
72 expect(spanOf("Kitchen")).toBeGreaterThan(spanOf("Garden"));
73 expect(spanOf("Garden")).toBeGreaterThan(spanOf("Living room"));
74 expect(spanOf("Living room")).toBe(spanOf("Porch"));
75 });
76
77 it("tells renderImage the sizes of the columns the tile sits in", () => {
78 const renderImage = vi.fn(() => <img alt="" src="/x.jpg" />);
79 renderGallery({ renderImage, columns: { xs: 1, md: 2, lg: 5 } });
80 expect(renderImage).toHaveBeenCalledWith(
81 IMAGES[0],
82 expect.objectContaining({
83 sizes: "(min-width: 64rem) 20vw, (min-width: 48rem) 50vw, 100vw",
84 }),
85 );
86 });
87
88 it("shows no caption under a thumbnail", () => {
89 renderGallery();
90 expect(screen.queryByText("Living room with the mural")).toBeNull();
91 });
92
93 it("shows a placeholder when an image fails and still opens the lightbox", async () => {
94 const { container } = renderGallery();
95 fireEvent.error(container.querySelector("img") as HTMLImageElement);
96 expect(screen.getByText("Image unavailable")).toBeInTheDocument();
97 // The visible text is part of the name, so speech input can say it (WCAG 2.5.3).
98 const failed = screen.getByRole("button", {
99 name: "Living room, Image unavailable",
100 });
101
102 await userEvent.click(failed);
103 expect(getDialog()).toBeInTheDocument();
104 });
105
106 it("shows an image that broke before React attached as unavailable", () => {
107 renderGallery({
108 images: [{ src: "/broken.jpg", alt: "Gone", width: 10, height: 10 }],
109 });
110 expect(screen.getByText("Image unavailable")).toBeInTheDocument();
111 });
112
113 it("hands each image to renderImage with its context and sizes", async () => {
114 const renderImage = vi.fn((image: { alt: string }) => (
115 <img alt={image.alt} src="/x.jpg" />
116 ));
117 renderGallery({ renderImage });
118 expect(renderImage).toHaveBeenCalledWith(
119 IMAGES[0],
120 expect.objectContaining({
121 context: "thumbnail",
122 sizes: expect.any(String),
123 }),
124 );
125
126 await userEvent.click(screen.getByRole("button", { name: "Living room" }));
127 expect(renderImage).toHaveBeenCalledWith(
128 IMAGES[0],
129 expect.objectContaining({ context: "lightbox", sizes: "100vw" }),
130 );
131 });
132});
133
134describe("masonry helpers", () => {
135 it("resolves numbers, partial objects and bad values to a count per breakpoint", () => {
136 expect(resolveColumns(5)).toEqual({ xs: 5, md: 5, lg: 5 });
137 expect(resolveColumns({ md: 6 })).toEqual({ xs: 2, md: 6, lg: 4 });
138 expect(resolveColumns(0).xs).toBe(1);
139 expect(resolveColumns(99).lg).toBe(8);
140 });
141
142 it("spans more rows for a taller image, within the classes the stylesheet has", () => {
143 const span = (width: number, height: number) =>
144 rowSpan({ src: "", alt: "", width, height });
145 expect(span(100, 200)).toBeGreaterThan(span(100, 100));
146 expect(span(100, 100)).toBeGreaterThan(span(200, 100));
147 expect(span(1000, 1)).toBeGreaterThanOrEqual(1);
148 expect(span(1, 1000)).toBe(150);
149 });
150});