Gallery
v0.1.4DraftA 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`.
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";89export 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}1920/** 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];2930 return image && openIndex !== null ? (31 <GalleryLightbox32 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";1213export 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}2526/** 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);3637 return (38 <ul role="list" className={getChildClass("list")}>39 {images.map((image, index) => (40 <GalleryThumbnail41 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";23/** 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";67type Direction = "previous" | "next";89const ENABLED: Record<Direction, (nav: LightboxSteps) => boolean> = {10 previous: (nav) => nav.canGoPrevious,11 next: (nav) => nav.canGoNext,12};1314const STEP: Record<Direction, (nav: LightboxSteps) => () => void> = {15 previous: (nav) => nav.goPrevious,16 next: (nav) => nav.goNext,17};1819const ICONS: Record<Direction, typeof ArrowLeftIcon> = {20 previous: ArrowLeftIcon,21 next: ArrowRightIcon,22};2324export 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}3132/** 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];4344 return (45 <Button46 className={getChildClass(direction)}47 variant="secondary"48 size="lg"49 iconOnly50 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";1415// 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});2627export 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}3940/** 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 });5657 useStrandedFocus(rootRef, nav.canGoPrevious, nav.canGoNext);5859 return (60 <div ref={rootRef} className={rootClass}>61 <GalleryLightboxArrow direction="previous" labels={labels} nav={nav} />62 <GalleryLightboxStage63 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";1213/** The `sizes` string for the lightbox image: it fills the viewport */14export const LIGHTBOX_SIZES = "100vw";1516export 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}2728/**29 * The lightbox's image area and swipe surface. While an image loads it shows a30 * placeholder of the image's own shape and a spinner; a failed image shows a31 * message. The image is keyed by `index`, so the previous bitmap is never left32 * 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 });5556 return (57 <div58 className={rootClass}59 aria-busy={status === "loading"}60 {...swipeHandlers}61 >62 <div ref={trackRef} className={getChildClass("track")}>63 <div64 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 <svg72 className={getChildClass("placeholder")}73 viewBox={`0 0 ${image.width} ${image.height}`}74 preserveAspectRatio="xMidYMid meet"75 aria-hidden="true"76 focusable="false"77 >78 <rect79 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 <Spinner96 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";45export interface GalleryLightboxTitleProps {6 /** Its position in `images[]` */7 index: number;8 /** How many images there are */9 total: number;10 labels: GalleryLabels;11}1213/**14 * The dialog title: a polite live region reading "Image 4 of 12", so each15 * 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.counter27 .replace("{current}", String(index + 1))28 .replace("{total}", String(total));2930 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";1112// 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};1920// 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);2627 if (target !== undefined) {28 event.preventDefault();29 onIndexChange(Math.min(Math.max(target, 0), last));30 }31 };3233export 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}4647/**48 * The lightbox: a fullscreen `Modal` with a counter title, Close, the current49 * image with its caption, and Previous and Next. It does not wrap: Previous is50 * disabled on the first image and Next on the last. Arrow keys, Home and End51 * 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 );6869 return (70 <Modal71 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 <GalleryLightboxBody80 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";1213export 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}2526/**27 * One grid tile: a button named by the image alt that opens the lightbox. Its28 * row span comes from the image's ratio, so it is the right height before the29 * 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 });4748 return (49 <li className={rootClass}>50 <button51 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";23/**4 * One image in the grid and the lightbox. A tile keeps the image's ratio up to5 * about 3:1 (height over width): the row span is capped at 150 rows, so a taller6 * image is cropped to that height in its thumbnail (`object-fit: cover`). The7 * 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}2122/** The breakpoints `columns` is set for: below 48rem, from 48rem, from 64rem. */23export type GalleryBreakpoint = "xs" | "md" | "lg";2425/** Column counts per breakpoint: `xs` below 48rem, `md` from 48rem, `lg` from 64rem */26export type GalleryColumnsConfig = Partial<Record<GalleryBreakpoint, number>>;2728/** A column count for every width, or a count per breakpoint. */29export type GalleryColumns = number | GalleryColumnsConfig;3031export 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}3738/** Draws an image; an app passes `next/image` here. */39export type GalleryRenderImage = (40 image: GalleryImage,41 options: GalleryRenderImageOptions,42) => ReactNode;4344/** 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}6364/** 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}7374/** 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";1516export 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, from26 * 48rem and from 64rem (any you omit keep the default). Up to 8. Defaults to27 * `{ 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 and32 * `{ context: "thumbnail" | "lightbox", sizes }`. Defaults to a lazy `<img>`.33 * Render a plain `<img>` (or a component that renders one): the grid fills34 * 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}4243// One root modifier per breakpoint (`xs-2`, `md-3`, `lg-4`), which the44// 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 );5253const defaultRenderImage: GalleryRenderImage = (image, { context }) => (54 <img55 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);6364/**65 * A responsive masonry image grid that opens a swipeable, keyboard-navigable66 * lightbox. It is one list in `images[]` order, so Tab, reading and lightbox67 * order agree; tiles keep each image's ratio and captions show in the lightbox68 * only. Built on `Modal`.69 */70export const Gallery = ({71 className,72 images,73 columns = DEFAULT_COLUMNS,74 renderImage = defaultRenderImage,75 onOpenChange,76 labels,77 ...rest78}: 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 };9293 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";67/** 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};1314/** Most columns the stylesheet has a class for. */15export const MAX_COLUMNS = 8;1617/**18 * Grid rows per column width (gallery.module.scss derives the row height from19 * it). More rows per column means a tile's height is closer to its image's20 * ratio, at the price of a bigger span number.21 */22export const ROWS_PER_COLUMN = 48;2324/**25 * Rows added to every span to cover the vertical gap (a margin on the tile) at26 * typical column widths, so tile height stays within a few px of the ratio.27 */28export const GAP_ROWS = 2.4;2930/** Tallest and flattest tile the stylesheet has a span class for. */31export const MAX_SPAN = 150;3233/**34 * Resolves the `columns` prop to a whole count per breakpoint. A number applies35 * 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 };4445 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};5253/**54 * How many grid rows a tile spans, from its image's height over width. Tiles of55 * 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 );6364/**65 * The `sizes` string for a thumbnail, from the columns it will sit in (the66 * 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`;7273 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";89/** Where an image is: still loading, shown, or failed. */10export type ImageStatus = "loading" | "loaded" | "error";1112interface 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}2223/**24 * Tracks whether the `<img>` inside a wrapper has loaded or failed, whatever25 * 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 finished27 * 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";3637 useEffect(() => {38 const img = ref.current?.querySelector("img");3940 // An image that settled before React attached fires no event later, so a41 // 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]);4950 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";23interface 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}78/**9 * Open state for the gallery lightbox, and where focus goes when it closes: the10 * 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);1819 const open = (index: number) => {20 shownIndex.current = index;21 setOpenIndex(index);22 onOpenChange?.(true, index);23 };2425 const changeIndex = (index: number) => {26 shownIndex.current = index;27 setOpenIndex(index);28 };2930 const close = () => {31 setOpenIndex(null);32 onOpenChange?.(false, shownIndex.current);33 };3435 const restoreFocus = (event: Event) => {36 const thumbnail = thumbnails.current[shownIndex.current];3738 event.preventDefault();39 thumbnail?.focus();40 thumbnail?.scrollIntoView?.({ block: "nearest" });41 };4243 const registerThumbnail =44 (index: number) => (element: HTMLButtonElement | null) => {45 thumbnails.current[index] = element;46 };4748 return {49 openIndex,50 open,51 changeIndex,52 close,53 restoreFocus,54 registerThumbnail,55 };56};5758/** 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";23/**4 * A disabled button cannot keep focus, so when Previous or Next becomes disabled5 * 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));2324 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";23/** 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";1314interface 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}2122interface Gesture {23 startX: number;24 startY: number;25 startTime: number;26 pointerId: number;27}2829type SwipeEvent = PointerEvent<HTMLElement>;3031/**32 * Touch swipe with pointer events and no dependency. The image follows the33 * finger by writing one custom property on the tracked element (re-rendering on34 * every move would be wasteful); the stylesheet turns it into a transform. A35 * swipe left goes next, right goes previous; at either end the drag rubber-bands36 * 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);4647 const setDrag = (offsetPx: number) => {48 const style = trackRef.current?.style;4950 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 };5758 // 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 };6667 // Only the first finger, or the main mouse button, starts a gesture; a second68 // finger or another pointer id never restarts or ends it.69 const isTracked = (event: SwipeEvent) =>70 gesture.current?.pointerId === event.pointerId;7172 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 };8384 const onPointerMove = (event: SwipeEvent) => {85 const start = gesture.current;8687 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;9192 if (Math.abs(dx) > Math.abs(dy)) {93 setDrag(blocked ? dx * RUBBER_BAND : dx);94 }95 }96 };9798 const onPointerUp = (event: SwipeEvent) => {99 const start = gesture.current;100101 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);110111 finish(event);112113 if (commits && dx < 0 && canGoNext) {114 onNext();115 } else if (commits && dx > 0 && canGoPrevious) {116 onPrevious();117 }118 }119 };120121 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's2// offset) and, while a finger is down, --gallery-drag-transition: none, so the3// 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 horizontal11 // movement to our pointer handlers.12 touch-action: pan-y;13 user-select: none;1415 &__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 );2526 @media (prefers-reduced-motion: reduce) {27 transition: none;28 }29 }3031 &__image {32 position: relative;33 width: 100%;34 height: 100%;3536 // 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 }4748 // 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 }5657 &__placeholder-fill {58 fill: var(--md-sys-color-surface-container-high);59 }6061 &__spinner {62 position: absolute;63 top: 50%;64 left: 50%;65 transform: translate(-50%, -50%);66 }6768 &__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;23// Below md the controls sit in a bottom bar under the image (thumb reach); from4// md they sit in the side gutters. They never overlap the photo, so their5// 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);2021 @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 }2930 &__previous {31 grid-area: previous;32 justify-self: start;33 align-self: center;34 }3536 &__next {37 grid-area: next;38 justify-self: end;39 align-self: center;40 }4142 &__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 }5253 &__visually-hidden {54 // Fixed px: the standard clip idiom for hiding text from sight while55 // 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-N2// modifier, N matching masonry.ts), so it is the right height before the image3// loads; the image fills it with object-fit: cover, which crops only the few px4// of rounding. The bottom margin is the vertical gap.5$max-span: 150;67.BHT__GalleryThumbnail {8 margin: 0 0 var(--gallery-gap);9 padding: 0;10 list-style: none;1112 @for $n from 1 through $max-span {13 &--span-#{$n} {14 grid-row-end: span #{$n};15 }16 }1718 &__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);3334 // 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 }4344 // 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);4748 &::after {49 opacity: var(--md-sys-state-hover-opacity, 0.08);50 }51 }5253 &:focus-visible {54 outline: 3px solid var(--md-sys-color-secondary);55 outline-offset: 2px;56 }57 }5859 &__media {60 position: relative;61 display: block;62 height: 100%;63 background: var(--md-sys-color-surface-container-high);6465 img {66 position: absolute;67 inset: 0;68 width: 100%;69 height: 100%;70 object-fit: cover;71 }72 }7374 &__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;23// Masonry from one CSS grid, so the list stays in images[] order. The root is4// an inline-size container so the list can size its rows from the container's5// width (100cqi): each row is 1/48 of a column's width, a tile spans the rows6// its image's ratio needs (see GalleryThumbnail), and the grid's own sparse7// auto-placement drops each tile into the column that frees up first. The8// vertical gap is a margin on the tile, not row-gap, so it never rounds into9// 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;1314.BHT__Gallery {15 --gallery-gap: var(--spacing-2);16 --cols: 2;1718 container-type: inline-size;19 color: var(--md-sys-color-on-surface);2021 @include breakpoints.md {22 --gallery-gap: var(--spacing-4);23 }2425 @for $n from 1 through $max-columns {26 &--xs-#{$n} {27 --cols: #{$n};28 }29 }3031 @include breakpoints.md {32 @for $n from 1 through $max-columns {33 &--md-#{$n} {34 --cols: #{$n};35 }36 }37 }3839 @include breakpoints.lg {40 @for $n from 1 through $max-columns {41 &--lg-#{$n} {42 --cols: #{$n};43 }44 }45 }4647 &__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";23// 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 )}`;89const 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;1920const 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}));2728/** 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} />;3031/** A fixed number of columns at every width. */32export const ThreeColumns = () => <Gallery images={IMAGES} columns={3} />;3334/** 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";89stubImageLoading();1011describe("Gallery lightbox", () => {12 const open = async (name = "Living room") => {13 await userEvent.click(screen.getByRole("button", { name }));14 };1516 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 });2627 it("opens at the tapped image", async () => {28 renderGallery();29 await open("Garden");30 expect(getDialog()).toHaveAccessibleName("Image 3 of 4");31 });3233 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 });4445 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 });5253 it("starts with focus on Close", async () => {54 renderGallery();55 await open();56 expect(screen.getByRole("button", { name: "Close" })).toHaveFocus();57 });5859 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 });6768 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 });7677 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");8586 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 });9192 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 });101102 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 });109110 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 });117118 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 });126127 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();135136 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 });140141 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);146147 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");156157 fireEvent.load(second);158 expect(dialog.querySelector("svg[class*=placeholder]")).toBeNull();159 });160161 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 });173174 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";89stubImageLoading();1011describe("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 };1819 fireEvent.pointerDown(stage, down);20 now.mockReturnValue(ms);21 fireEvent.pointerMove(stage, end);22 fireEvent.pointerUp(stage, end);23 now.mockRestore();24 };2526 const openAt = async (name: string) => {27 renderGallery();28 await userEvent.click(screen.getByRole("button", { name }));29 };3031 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 });3637 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 });4243 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 });4849 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 });5455 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 });6061 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 });6667 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";1011stubImageLoading();1213describe("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 });2324 it("renders nothing when there are no images", () => {25 const { container } = renderGallery({ images: [] });26 expect(container).toBeEmptyDOMElement();27 });2829 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 });3940 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 });4748 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 });5556 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 });6364 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 });7677 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 });8788 it("shows no caption under a thumbnail", () => {89 renderGallery();90 expect(screen.queryByText("Living room with the mural")).toBeNull();91 });9293 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 });101102 await userEvent.click(failed);103 expect(getDialog()).toBeInTheDocument();104 });105106 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 });112113 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 );125126 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});133134describe("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 });141142 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});