Skip to content
Source
Sign inSign up
Open History to see the last commit

ExpandedImageDialog.browser.test.tsx

190 lines7.4 KB

1import { useState } from "react";
2import type { SnapShotSource } from "@t3tools/contracts";
3import { describe, expect, it, vi } from "vite-plus/test";
4import { page, userEvent } from "vite-plus/test/browser";
5​
6import "~/index.css";
7import { render } from "../../../test/render";
8import { ExpandedImageDialog } from "./ExpandedImageDialog";
9​
10const PIXEL =
11 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
12​
13const snapShotSource: SnapShotSource = {
14 kind: "snap-shot",
15 capturedAt: "2026-01-01T00:00:00.000Z",
16 appName: "Finder",
17 windowTitle: "Downloads",
18};
19​
20const D2_SVG =
21 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1174 920"><rect width="1174" height="920"/></svg>';
22​
23function ImagePreviewOpener() {
24 const [open, setOpen] = useState(false);
25 return (
26 <>
27 <button type="button" onClick={() => setOpen(true)}>
28 Open preview
29 </button>
30 {open && (
31 <ExpandedImageDialog
32 preview={{ images: [{ src: PIXEL, name: "shot.png", source: snapShotSource }], index: 0 }}
33 onClose={() => setOpen(false)}
34 />
35 )}
36 </>
37 );
39​
40describe("ExpandedImageDialog", () => {
41 it.each([
42 { opener: "mouse", focusVisible: false },
43 { opener: "keyboard", focusVisible: true },
44 ])("returns $opener focus to the opener on close", async (c) => {
45 await using screen = await render(<ImagePreviewOpener />, {
46 // The opener renders no links and reads no route.
47 router: false,
48 });
49 const opener = screen.getByRole("button", { name: "Open preview" });
50 if (c.opener === "mouse") {
51 await userEvent.click(opener);
52 } else {
53 await userEvent.keyboard("{Tab}");
54 await userEvent.keyboard("{Enter}");
55 }
56 await expect.element(page.getByRole("button", { name: "Close image preview" })).toHaveFocus();
57 await userEvent.keyboard("{Escape}");
58 await expect.element(opener).toHaveFocus();
59 expect(opener.element().matches(":focus-visible")).toBe(c.focusVisible);
60 });
61​
62 it.each(["/work/pipeline.svg", "/work/diagram#v2.svg", "/work/diagram?v2.svg"])(
63 "fits a markdown SVG without intrinsic size to the viewer: %s",
64 async (path) => {
65 const src = URL.createObjectURL(new Blob([D2_SVG], { type: "image/svg+xml" }));
66 using _src = { [Symbol.dispose]: () => URL.revokeObjectURL(src) };
67 await using screen = await render(
68 <ExpandedImageDialog
69 preview={{
70 images: [
71 {
72 src,
73 name: "Pipeline",
74 actionsSource: {
75 kind: "image",
76 name: "Pipeline",
77 src,
78 reference: { kind: "file", path },
79 },
80 },
81 ],
82 index: 0,
83 }}
84 onClose={vi.fn()}
85 />,
86 // The dialog renders no links and reads no route.
87 { router: false },
88 );
89​
90 const viewer = screen.getByRole("region", { name: "Pipeline, zoomable image" });
91 const availableWidth = window.innerWidth * 0.92 - (window.innerWidth >= 640 ? 96 : 0);
92 await vi.waitFor(() =>
93 expect(viewer.element().getBoundingClientRect().width).toBeCloseTo(availableWidth, 0),
94 );
95 },
96 );
97​
98 it("Escape closes a popup opened inside the dialog before the dialog", async () => {
99 const onClose = vi.fn();
100 await using screen = await render(
101 <ExpandedImageDialog
102 preview={{ images: [{ src: PIXEL, name: "shot.png", source: snapShotSource }], index: 0 }}
103 onClose={onClose}
104 />,
105 // The dialog renders no links and reads no route.
106 { router: false },
107 );
108​
109 await userEvent.click(screen.getByRole("button", { name: "No accessibility data" }));
110 await expect.element(screen.getByText("Accessibility data")).toBeVisible();
111​
112 await userEvent.keyboard("{Escape}");
113 await expect.element(screen.getByText("Accessibility data")).not.toBeInTheDocument();
114 expect(onClose).not.toHaveBeenCalled();
115​
116 // Focus returns to the popup's trigger and opens its tooltip; leave it for a plain control.
117 (screen.getByRole("button", { name: "Close image preview" }).element() as HTMLElement).focus();
118 await userEvent.keyboard("{Escape}");
119 await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce());
120 });
121​
122 it("scrolling pans the image while Ctrl+wheel zooms around the pointer", async () => {
123 const canvas = document.createElement("canvas");
124 canvas.width = 1600;
125 canvas.height = 1200;
126 await using screen = await render(
127 <ExpandedImageDialog
128 preview={{ images: [{ src: canvas.toDataURL(), name: "large.png" }], index: 0 }}
129 onClose={() => {}}
130 />,
131 // The dialog renders no links and reads no route.
132 { router: false },
133 );
134 const viewportLocator = screen.getByRole("region", { name: "large.png, zoomable image" });
135 const viewport = viewportLocator.element() as HTMLElement;
136 const image = screen.getByRole("img", { name: "large.png" }).element() as HTMLElement;
137 await vi.waitFor(() => expect(image.style.width).not.toBe(""));
138 const fitWidth = image.getBoundingClientRect().width;
139 const wheels: { ctrlKey: boolean; canceled: boolean }[] = [];
140 const recordWheel = (event: WheelEvent) =>
141 wheels.push({ ctrlKey: event.ctrlKey, canceled: event.defaultPrevented });
142 document.addEventListener("wheel", recordWheel);
143 using _wheelListener = {
144 [Symbol.dispose]: () => document.removeEventListener("wheel", recordWheel),
145 };
146 // Wheels land on the viewport's center; this is the image fraction under it.
147 const pointerFraction = () => {
148 const viewportBounds = viewport.getBoundingClientRect();
149 const imageBounds = image.getBoundingClientRect();
150 return {
151 x: (viewportBounds.left + viewportBounds.width / 2 - imageBounds.left) / imageBounds.width,
152 y: (viewportBounds.top + viewportBounds.height / 2 - imageBounds.top) / imageBounds.height,
153 };
154 };
155 const ctrlWheel = async (deltaY: number) => {
156 await userEvent.keyboard("{Control>}");
157 await userEvent.wheel(viewportLocator, { delta: { y: deltaY } });
158 await userEvent.keyboard("{/Control}");
159 };
160​
161 await userEvent.wheel(viewportLocator, { delta: { y: 120 } });
162 expect(wheels.at(-1)).toEqual({ ctrlKey: false, canceled: false });
163 expect(image.getBoundingClientRect().width).toBe(fitWidth);
164​
165 await ctrlWheel(-100);
166 expect(wheels.at(-1)).toEqual({ ctrlKey: true, canceled: true });
167 const zoomedWidth = image.getBoundingClientRect().width;
168 expect(zoomedWidth).toBeCloseTo(fitWidth * Math.E, 0);
169​
170 const pan = async (delta: { x: number; y: number }) => {
171 const { scrollLeft, scrollTop } = viewport;
172 await userEvent.wheel(viewportLocator, { delta });
173 await vi.waitFor(() => {
174 expect(Math.sign(viewport.scrollLeft - scrollLeft)).toBe(Math.sign(delta.x));
175 expect(Math.sign(viewport.scrollTop - scrollTop)).toBe(Math.sign(delta.y));
176 });
177 };
178 await pan({ x: 60, y: 0 });
179 await pan({ x: 0, y: 60 });
180 await pan({ x: -40, y: -40 });
181 expect(wheels.filter((wheel) => !wheel.ctrlKey).every((wheel) => !wheel.canceled)).toBe(true);
182 expect(image.getBoundingClientRect().width).toBe(zoomedWidth);
183​
184 const anchor = pointerFraction();
185 await ctrlWheel(-50);
186 expect(image.getBoundingClientRect().width).toBeGreaterThan(zoomedWidth);
187 expect(pointerFraction().x).toBeCloseTo(anchor.x, 2);
188 expect(pointerFraction().y).toBeCloseTo(anchor.y, 2);
189 });
190});