a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import {
2 namedViewTransition,
3 startViewTransition,
4 supportsViewTransitions,
5 withViewTransition,
6} from "$core/view-transitions";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
9describe("View Transitions API", () => {
10 let mockStartViewTransition: ReturnType<typeof vi.fn>;
11 let originalStartViewTransition: unknown;
12 let originalMatchMedia: unknown;
13
14 beforeEach(() => {
15 mockStartViewTransition = vi.fn((callback: () => void | Promise<void>) => {
16 const result = callback();
17 return {
18 finished: Promise.resolve(result).then(() => {}),
19 ready: Promise.resolve(),
20 updateCallbackDone: Promise.resolve(result).then(() => {}),
21 skipTransition: vi.fn(),
22 };
23 });
24
25 originalStartViewTransition = (document as Document & { startViewTransition?: unknown }).startViewTransition;
26 originalMatchMedia = globalThis.matchMedia;
27
28 (document as Document & { startViewTransition: typeof mockStartViewTransition }).startViewTransition =
29 mockStartViewTransition;
30
31 globalThis.matchMedia = vi.fn((query: string) => ({
32 matches: query === "(prefers-reduced-motion: reduce)" ? false : false,
33 media: query,
34 onchange: null,
35 addListener: vi.fn(),
36 removeListener: vi.fn(),
37 addEventListener: vi.fn(),
38 removeEventListener: vi.fn(),
39 dispatchEvent: vi.fn(),
40 })) as typeof globalThis.matchMedia;
41 });
42
43 afterEach(() => {
44 if (originalStartViewTransition === undefined) {
45 // @ts-expect-error mocking browser without view transitions API
46 delete (document as Document & { startViewTransition?: unknown }).startViewTransition;
47 } else {
48 // @ts-expect-error mocking view transitions API
49 (document as Document & { startViewTransition: unknown }).startViewTransition = originalStartViewTransition;
50 }
51
52 globalThis.matchMedia = originalMatchMedia as typeof globalThis.matchMedia;
53
54 vi.restoreAllMocks();
55 });
56
57 describe("supportsViewTransitions", () => {
58 it("should return true when View Transitions API is supported", () => {
59 expect(supportsViewTransitions()).toBe(true);
60 });
61
62 it("should return false when View Transitions API is not supported", () => {
63 // @ts-expect-error mocking browser without view transitions API
64 delete (document as Document & { startViewTransition?: unknown }).startViewTransition;
65 expect(supportsViewTransitions()).toBe(false);
66 });
67 });
68
69 describe("startViewTransition", () => {
70 it("should use View Transitions API when supported", async () => {
71 const callback = vi.fn();
72
73 await startViewTransition(callback);
74
75 expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
76 expect(callback).toHaveBeenCalled();
77 });
78
79 it("should fallback to direct execution when API is not supported", async () => {
80 // @ts-expect-error mocking browser without view transitions API
81 delete (document as Document & { startViewTransition?: unknown }).startViewTransition;
82
83 const callback = vi.fn();
84 await startViewTransition(callback);
85
86 expect(callback).toHaveBeenCalled();
87 });
88
89 it("should skip transition when prefers-reduced-motion is enabled", async () => {
90 globalThis.matchMedia = vi.fn((query: string) => ({
91 matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
92 media: query,
93 onchange: null,
94 addListener: vi.fn(),
95 removeListener: vi.fn(),
96 addEventListener: vi.fn(),
97 removeEventListener: vi.fn(),
98 dispatchEvent: vi.fn(),
99 })) as typeof globalThis.matchMedia;
100
101 const callback = vi.fn();
102 await startViewTransition(callback);
103
104 expect(mockStartViewTransition).not.toHaveBeenCalled();
105 expect(callback).toHaveBeenCalled();
106 });
107
108 it("should force fallback when forceFallback option is true", async () => {
109 const callback = vi.fn();
110 await startViewTransition(callback, { forceFallback: true });
111
112 expect(mockStartViewTransition).not.toHaveBeenCalled();
113 expect(callback).toHaveBeenCalled();
114 });
115
116 it("should respect reduced motion preference when respectReducedMotion is true", async () => {
117 globalThis.matchMedia = vi.fn((query: string) => ({
118 matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
119 media: query,
120 onchange: null,
121 addListener: vi.fn(),
122 removeListener: vi.fn(),
123 addEventListener: vi.fn(),
124 removeEventListener: vi.fn(),
125 dispatchEvent: vi.fn(),
126 })) as typeof globalThis.matchMedia;
127
128 const callback = vi.fn();
129 await startViewTransition(callback, { respectReducedMotion: true });
130
131 expect(mockStartViewTransition).not.toHaveBeenCalled();
132 expect(callback).toHaveBeenCalled();
133 });
134
135 it("should use View Transitions API when respectReducedMotion is false even with reduced motion", async () => {
136 globalThis.matchMedia = vi.fn((query: string) => ({
137 matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
138 media: query,
139 onchange: null,
140 addListener: vi.fn(),
141 removeListener: vi.fn(),
142 addEventListener: vi.fn(),
143 removeEventListener: vi.fn(),
144 dispatchEvent: vi.fn(),
145 })) as typeof globalThis.matchMedia;
146
147 const callback = vi.fn();
148 await startViewTransition(callback, { respectReducedMotion: false });
149
150 expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
151 expect(callback).toHaveBeenCalled();
152 });
153
154 it("should apply and remove view-transition-name for named transitions", async () => {
155 const element = document.createElement("div");
156 const callback = vi.fn();
157
158 await startViewTransition(callback, { name: "test-transition", elements: [element] });
159
160 expect(callback).toHaveBeenCalled();
161 expect(element.style.viewTransitionName).toBe("");
162 });
163
164 it("should apply unique names for multiple elements", async () => {
165 const element1 = document.createElement("div");
166 const element2 = document.createElement("div");
167 const callback = vi.fn(() => {
168 expect(element1.style.viewTransitionName).toBe("test-transition-0");
169 expect(element2.style.viewTransitionName).toBe("test-transition-1");
170 });
171
172 await startViewTransition(callback, { name: "test-transition", elements: [element1, element2] });
173
174 expect(callback).toHaveBeenCalled();
175 expect(element1.style.viewTransitionName).toBe("");
176 expect(element2.style.viewTransitionName).toBe("");
177 });
178
179 it("should restore original view-transition-name values", async () => {
180 const element = document.createElement("div");
181 element.style.viewTransitionName = "original-name";
182
183 const callback = vi.fn(() => {
184 expect(element.style.viewTransitionName).toBe("test-transition");
185 });
186
187 await startViewTransition(callback, { name: "test-transition", elements: [element] });
188
189 expect(callback).toHaveBeenCalled();
190 expect(element.style.viewTransitionName).toBe("original-name");
191 });
192
193 it("should handle async callbacks", async () => {
194 const callback = vi.fn(async () => {
195 await new Promise((resolve) => setTimeout(resolve, 10));
196 });
197
198 await startViewTransition(callback);
199
200 expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
201 expect(callback).toHaveBeenCalled();
202 });
203 });
204
205 describe("namedViewTransition", () => {
206 it("should apply named transition to single element", async () => {
207 const element = document.createElement("div");
208 const callback = vi.fn(() => {
209 expect(element.style.viewTransitionName).toBe("card-flip");
210 });
211
212 await namedViewTransition("card-flip", [element], callback);
213
214 expect(callback).toHaveBeenCalled();
215 expect(element.style.viewTransitionName).toBe("");
216 });
217
218 it("should apply named transition to multiple elements", async () => {
219 const element1 = document.createElement("div");
220 const element2 = document.createElement("div");
221 const callback = vi.fn(() => {
222 expect(element1.style.viewTransitionName).toBe("card-flip-0");
223 expect(element2.style.viewTransitionName).toBe("card-flip-1");
224 });
225
226 await namedViewTransition("card-flip", [element1, element2], callback);
227
228 expect(callback).toHaveBeenCalled();
229 expect(element1.style.viewTransitionName).toBe("");
230 expect(element2.style.viewTransitionName).toBe("");
231 });
232 });
233
234 describe("withViewTransition", () => {
235 it("should use View Transitions API when supported", () => {
236 const callback = vi.fn();
237
238 withViewTransition(callback);
239
240 expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
241 expect(callback).toHaveBeenCalled();
242 });
243
244 it("should fallback to direct execution when API is not supported", () => {
245 // @ts-expect-error mocking browser without view transitions API
246 delete (document as Document & { startViewTransition?: unknown }).startViewTransition;
247
248 const callback = vi.fn();
249 withViewTransition(callback);
250
251 expect(callback).toHaveBeenCalled();
252 });
253
254 it("should skip transition when prefers-reduced-motion is enabled and respectReducedMotion is true", () => {
255 globalThis.matchMedia = vi.fn((query: string) => ({
256 matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
257 media: query,
258 onchange: null,
259 addListener: vi.fn(),
260 removeListener: vi.fn(),
261 addEventListener: vi.fn(),
262 removeEventListener: vi.fn(),
263 dispatchEvent: vi.fn(),
264 })) as typeof globalThis.matchMedia;
265
266 const callback = vi.fn();
267 withViewTransition(callback, true);
268
269 expect(mockStartViewTransition).not.toHaveBeenCalled();
270 expect(callback).toHaveBeenCalled();
271 });
272
273 it("should use View Transitions API when respectReducedMotion is false", () => {
274 globalThis.matchMedia = vi.fn((query: string) => ({
275 matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
276 media: query,
277 onchange: null,
278 addListener: vi.fn(),
279 removeListener: vi.fn(),
280 addEventListener: vi.fn(),
281 removeEventListener: vi.fn(),
282 dispatchEvent: vi.fn(),
283 })) as typeof globalThis.matchMedia;
284
285 const callback = vi.fn();
286 withViewTransition(callback, false);
287
288 expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
289 expect(callback).toHaveBeenCalled();
290 });
291 });
292});