a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { shiftPlugin } from "$plugins/shift";
2import { surgePlugin } from "$plugins/surge";
3import type { TransitionPreset } from "$types/volt";
4import { mount, registerPlugin, registerTransition, signal } from "$volt";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
7describe("integration: transitions", () => {
8 beforeEach(() => {
9 registerPlugin("surge", surgePlugin);
10 registerPlugin("shift", shiftPlugin);
11 globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false });
12 });
13
14 afterEach(() => {
15 vi.restoreAllMocks();
16 });
17
18 describe("Surge with data-volt-if", () => {
19 it("should animate element in when condition becomes true", async () => {
20 vi.useFakeTimers();
21
22 const container = document.createElement("div");
23 container.innerHTML = `<div data-volt-if="show" data-volt-surge="fade">Content</div>`;
24
25 const show = signal(false);
26 mount(container, { show });
27
28 const comment = [...container.childNodes].find((node) => node.nodeType === 8);
29 expect(comment).toBeDefined();
30
31 show.set(true);
32
33 await vi.advanceTimersByTimeAsync(400);
34
35 const element = container.querySelector("div");
36 expect(element).toBeDefined();
37 if (element) {
38 expect(element.textContent).toContain("Content");
39 }
40
41 vi.useRealTimers();
42 });
43
44 it("should animate element out when condition becomes false", async () => {
45 vi.useFakeTimers();
46
47 const container = document.createElement("div");
48 container.innerHTML = `
49 <div data-volt-if="show" data-volt-surge="fade">
50 Content
51 </div>
52 `;
53
54 const show = signal(true);
55 mount(container, { show });
56
57 let element = container.querySelector("div");
58 expect(element).toBeDefined();
59
60 show.set(false);
61
62 await vi.advanceTimersByTimeAsync(400);
63
64 element = container.querySelector("div");
65 expect(element).toBeNull();
66
67 vi.useRealTimers();
68 });
69
70 it("should support custom enter/leave transitions", async () => {
71 vi.useFakeTimers();
72
73 const container = document.createElement("div");
74 container.innerHTML = `
75 <div
76 data-volt-if="show"
77 data-volt-surge:enter="slide-down"
78 data-volt-surge:leave="fade">
79 Content
80 </div>
81 `;
82
83 const show = signal(false);
84 mount(container, { show });
85
86 show.set(true);
87 await vi.advanceTimersByTimeAsync(400);
88
89 let element = container.querySelector("div");
90 expect(element).toBeDefined();
91
92 show.set(false);
93 await vi.advanceTimersByTimeAsync(400);
94
95 element = container.querySelector("div");
96 expect(element).toBeNull();
97
98 vi.useRealTimers();
99 });
100
101 it("should work with if/else pattern", async () => {
102 vi.useFakeTimers();
103
104 const container = document.createElement("div");
105 container.innerHTML = `
106 <div data-volt-if="show" data-volt-surge="fade">
107 Shown
108 </div>
109 <div data-volt-else data-volt-surge="fade">
110 Hidden
111 </div>
112 `;
113
114 const show = signal(true);
115 mount(container, { show });
116
117 await vi.advanceTimersByTimeAsync(400);
118
119 let shownEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Shown"));
120 let hiddenEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Hidden"));
121
122 expect(shownEl).toBeDefined();
123 expect(hiddenEl).toBeUndefined();
124
125 show.set(false);
126 await vi.advanceTimersByTimeAsync(400);
127
128 shownEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Shown"));
129 hiddenEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Hidden"));
130
131 expect(shownEl).toBeUndefined();
132 expect(hiddenEl).toBeDefined();
133
134 vi.useRealTimers();
135 });
136
137 it("should support duration and delay modifiers", async () => {
138 vi.useFakeTimers();
139
140 const container = document.createElement("div");
141 container.innerHTML = `
142 <div data-volt-if="show" data-volt-surge="fade.500.100">
143 Content
144 </div>
145 `;
146
147 const show = signal(false);
148 mount(container, { show });
149
150 show.set(true);
151 await vi.advanceTimersByTimeAsync(650);
152
153 const element = container.querySelector("div");
154 expect(element).toBeDefined();
155
156 vi.useRealTimers();
157 });
158 });
159
160 describe("Surge with data-volt-show", () => {
161 it("should toggle display property with transition", async () => {
162 vi.useFakeTimers();
163
164 const container = document.createElement("div");
165 const testEl = document.createElement("div");
166 testEl.dataset.voltShow = "visible";
167 testEl.dataset.voltSurge = "fade";
168 testEl.textContent = "Content";
169 container.append(testEl);
170
171 const visible = signal(true);
172
173 const element = testEl;
174
175 mount(container, { visible });
176
177 expect(element.style.display).not.toBe("none");
178
179 visible.set(false);
180 await vi.advanceTimersByTimeAsync(400);
181
182 expect(element.style.display).toBe("none");
183
184 visible.set(true);
185 await vi.advanceTimersByTimeAsync(400);
186
187 expect(element.style.display).not.toBe("none");
188
189 vi.useRealTimers();
190 });
191
192 it("should not start overlapping transitions", async () => {
193 vi.useFakeTimers();
194
195 const container = document.createElement("div");
196 container.innerHTML = `
197 <div data-volt-show="visible" data-volt-surge="fade">
198 Content
199 </div>
200 `;
201
202 const visible = signal(true);
203 mount(container, { visible });
204
205 const element = container.querySelector("div") as HTMLElement;
206
207 visible.set(false);
208 visible.set(true);
209 visible.set(false);
210
211 await vi.advanceTimersByTimeAsync(50);
212
213 expect(element).toBeDefined();
214
215 vi.useRealTimers();
216 });
217 });
218
219 describe("Surge signal-triggered mode", () => {
220 it("should watch signal and apply transitions", async () => {
221 vi.useFakeTimers();
222
223 const container = document.createElement("div");
224 const testEl = document.createElement("div");
225 testEl.dataset.voltSurge = "show:fade";
226 testEl.textContent = "Content";
227 container.append(testEl);
228
229 const show = signal(false);
230
231 const element = testEl;
232
233 mount(container, { show });
234
235 expect(element.style.display).toBe("none");
236
237 show.set(true);
238 await vi.advanceTimersByTimeAsync(400);
239
240 expect(element.style.display).not.toBe("none");
241
242 show.set(false);
243 await vi.advanceTimersByTimeAsync(400);
244
245 expect(element.style.display).toBe("none");
246
247 vi.useRealTimers();
248 });
249
250 it("should cleanup subscription on unmount", async () => {
251 const container = document.createElement("div");
252 const testEl = document.createElement("div");
253 testEl.dataset.voltSurge = "show:fade";
254 testEl.textContent = "Content";
255 container.append(testEl);
256
257 const show = signal(false);
258 const element = testEl;
259
260 const cleanup = mount(container, { show });
261
262 expect(element.style.display).toBe("none");
263
264 cleanup();
265
266 const initialDisplay = element.style.display;
267 show.set(true);
268
269 await new Promise((resolve) => {
270 setTimeout(resolve, 50);
271 });
272
273 expect(element.style.display).toBe(initialDisplay);
274 });
275 });
276
277 describe("Shift animations", () => {
278 it("should apply animation on mount", async () => {
279 const container = document.createElement("div");
280 const testEl = document.createElement("div");
281 testEl.dataset.voltShift = "bounce";
282 testEl.textContent = "Content";
283 container.append(testEl);
284
285 const element = testEl;
286
287 mount(container, {});
288
289 // Wait for requestAnimationFrame to apply the animation
290 await vi.waitFor(() => {
291 expect(element.dataset.voltShiftRuns).toBe("1");
292 expect(element.style.animationName).toMatch(/^volt-shift-/);
293 });
294 });
295
296 it("should trigger animation based on signal", () => {
297 const container = document.createElement("div");
298 container.innerHTML = `
299 <button data-volt-shift="trigger:bounce">
300 Click me
301 </button>
302 `;
303
304 const trigger = signal(false);
305 mount(container, { trigger });
306
307 const button = container.querySelector("button") as HTMLElement;
308 expect(button.dataset.voltShiftRuns ?? "0").toBe("0");
309
310 trigger.set(true);
311 expect(button.dataset.voltShiftRuns).toBe("1");
312 });
313
314 it("should support duration and iteration modifiers", async () => {
315 const container = document.createElement("div");
316 const testEl = document.createElement("div");
317 testEl.dataset.voltShift = "bounce.1000.3";
318 testEl.textContent = "Content";
319 container.append(testEl);
320
321 const element = testEl;
322
323 mount(container, {});
324
325 // Wait for requestAnimationFrame to apply the animation
326 await vi.waitFor(() => {
327 expect(element.dataset.voltShiftRuns).toBe("1");
328 expect(element.style.animationDuration).toBe("1000ms");
329 expect(element.style.animationIterationCount).toBe("3");
330 });
331 });
332
333 it("should cleanup signal subscription on unmount", () => {
334 const container = document.createElement("div");
335 container.innerHTML = `
336 <button data-volt-shift="trigger:bounce">
337 Click me
338 </button>
339 `;
340
341 const trigger = signal(false);
342 const cleanup = mount(container, { trigger });
343
344 cleanup();
345
346 const button = container.querySelector("button") as HTMLElement;
347 trigger.set(true);
348
349 expect(button.dataset.voltShiftRuns ?? "0").toBe("0");
350 });
351 });
352
353 describe("Custom presets", () => {
354 it("should use registered custom transition preset", async () => {
355 vi.useFakeTimers();
356
357 const customPreset: TransitionPreset = {
358 enter: {
359 from: { opacity: 0, transform: "scale(0.5)" },
360 to: { opacity: 1, transform: "scale(1)" },
361 duration: 200,
362 easing: "ease-out",
363 },
364 leave: {
365 from: { opacity: 1, transform: "scale(1)" },
366 to: { opacity: 0, transform: "scale(0.5)" },
367 duration: 200,
368 easing: "ease-in",
369 },
370 };
371
372 registerTransition("custom-scale", customPreset);
373
374 const container = document.createElement("div");
375 container.innerHTML = `
376 <div data-volt-if="show" data-volt-surge="custom-scale">
377 Content
378 </div>
379 `;
380
381 const show = signal(false);
382 mount(container, { show });
383
384 show.set(true);
385 await vi.advanceTimersByTimeAsync(300);
386
387 const element = container.querySelector("div");
388 expect(element).toBeDefined();
389
390 vi.useRealTimers();
391 });
392 });
393
394 describe("Accessibility: prefers-reduced-motion", () => {
395 it("should skip animations when user prefers reduced motion", async () => {
396 globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true });
397
398 const container = document.createElement("div");
399 container.innerHTML = `
400 <div data-volt-if="show" data-volt-surge="fade">
401 Content
402 </div>
403 `;
404
405 const show = signal(false);
406 mount(container, { show });
407
408 show.set(true);
409
410 await new Promise((resolve) => {
411 setTimeout(resolve, 50);
412 });
413
414 const element = container.querySelector("div");
415 expect(element).toBeDefined();
416 });
417 });
418
419 describe("View Transitions API", () => {
420 it("should use View Transitions API when available", async () => {
421 const mockStartViewTransition = vi.fn((callback: () => void | Promise<void>) => {
422 const result = callback();
423 return {
424 finished: Promise.resolve(result).then(() => {}),
425 ready: Promise.resolve(),
426 updateCallbackDone: Promise.resolve(result).then(() => {}),
427 skipTransition: vi.fn(),
428 };
429 });
430
431 // @ts-expect-error - Adding View Transitions API mock
432 document.startViewTransition = mockStartViewTransition;
433
434 const { startViewTransition } = await import("$core/view-transitions");
435
436 await startViewTransition(() => {
437 const el = document.createElement("div");
438 el.textContent = "test";
439 });
440
441 expect(mockStartViewTransition).toHaveBeenCalled();
442
443 // @ts-expect-error - Cleanup mock
444 delete document.startViewTransition;
445 });
446
447 it("should fallback to CSS when View Transitions API not available", async () => {
448 // @ts-expect-error - Ensure View Transitions API is not available
449 delete document.startViewTransition;
450
451 vi.useFakeTimers();
452
453 const container = document.createElement("div");
454 container.innerHTML = `
455 <div data-volt-if="show" data-volt-surge="fade">
456 Content
457 </div>
458 `;
459
460 const show = signal(false);
461 mount(container, { show });
462
463 show.set(true);
464 await vi.advanceTimersByTimeAsync(400);
465
466 const element = container.querySelector("div");
467 expect(element).toBeDefined();
468
469 vi.useRealTimers();
470 });
471 });
472
473 describe("Memory leak prevention", () => {
474 it("should cleanup all transition-related subscriptions", async () => {
475 const container = document.createElement("div");
476 container.innerHTML = `
477 <div data-volt-if="show" data-volt-surge="fade">
478 Content 1
479 </div>
480 <div data-volt-show="visible" data-volt-surge="slide-down">
481 Content 2
482 </div>
483 <div data-volt-surge="trigger:scale">
484 Content 3
485 </div>
486 <button data-volt-shift="animTrigger:bounce">
487 Content 4
488 </button>
489 `;
490
491 const show = signal(false);
492 const visible = signal(true);
493 const trigger = signal(false);
494 const animTrigger = signal(false);
495
496 const cleanup = mount(container, { show, visible, trigger, animTrigger });
497
498 cleanup();
499
500 const initialHTML = container.innerHTML;
501
502 show.set(true);
503 visible.set(false);
504 trigger.set(true);
505 animTrigger.set(true);
506
507 await new Promise((resolve) => {
508 setTimeout(resolve, 50);
509 });
510
511 expect(container.innerHTML).toBe(initialHTML);
512 });
513 });
514
515 describe("Complex integration scenarios", () => {
516 it("should handle multiple animated elements simultaneously", async () => {
517 vi.useFakeTimers();
518
519 const container = document.createElement("div");
520 container.innerHTML = `
521 <div data-volt-if="show1" data-volt-surge="fade">Item 1</div>
522 <div data-volt-if="show2" data-volt-surge="slide-down">Item 2</div>
523 <div data-volt-if="show3" data-volt-surge="scale">Item 3</div>
524 `;
525
526 const show1 = signal(false);
527 const show2 = signal(false);
528 const show3 = signal(false);
529
530 mount(container, { show1, show2, show3 });
531
532 show1.set(true);
533 show2.set(true);
534 show3.set(true);
535
536 await vi.advanceTimersByTimeAsync(400);
537
538 const elements = container.querySelectorAll("div");
539 expect(elements.length).toBe(3);
540
541 vi.useRealTimers();
542 });
543
544 it("should combine surge and shift on same element", async () => {
545 const container = document.createElement("div");
546 const testEl = document.createElement("div");
547 testEl.dataset.voltShow = "visible";
548 testEl.dataset.voltSurge = "fade";
549 testEl.dataset.voltShift = "bounce";
550 testEl.textContent = "Combined";
551 container.append(testEl);
552
553 const element = testEl;
554
555 const visible = signal(true);
556 mount(container, { visible });
557
558 await new Promise((resolve) => {
559 setTimeout(resolve, 100);
560 });
561
562 expect(element.dataset.voltShiftRuns).toBe("1");
563 });
564 });
565});