a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { mount } from "$core/binder";
2import { registerPlugin } from "$core/plugin";
3import { signal } from "$core/signal";
4import { scrollPlugin } from "$plugins/scroll";
5import { beforeEach, describe, expect, it, vi } from "vitest";
6
7describe("scroll plugin", () => {
8 beforeEach(() => {
9 registerPlugin("scroll", scrollPlugin);
10 });
11
12 describe("restore mode", () => {
13 it("restores scroll position from signal on mount", () => {
14 const element = document.createElement("div");
15 element.dataset.voltScroll = "restore:scrollPos";
16 Object.defineProperty(element, "scrollTop", { writable: true, value: 0 });
17
18 const scrollPos = signal(250);
19 mount(element, { scrollPos });
20 expect(element.scrollTop).toBe(250);
21 });
22
23 it("saves scroll position to signal on scroll", () => {
24 const element = document.createElement("div");
25 element.dataset.voltScroll = "restore:scrollPos";
26
27 const scrollPos = signal(0);
28 mount(element, { scrollPos });
29
30 Object.defineProperty(element, "scrollTop", { writable: true, value: 100 });
31
32 element.dispatchEvent(new Event("scroll"));
33
34 expect(scrollPos.get()).toBe(100);
35 });
36
37 it("does not restore if signal value is not a number", () => {
38 const element = document.createElement("div");
39 element.dataset.voltScroll = "restore:scrollPos";
40 Object.defineProperty(element, "scrollTop", { writable: true, value: 0 });
41
42 const scrollPos = signal("not a number" as unknown as number);
43 mount(element, { scrollPos });
44 expect(element.scrollTop).toBe(0);
45 });
46
47 it("cleans up scroll listener on unmount", () => {
48 const element = document.createElement("div");
49 element.dataset.voltScroll = "restore:scrollPos";
50
51 const scrollPos = signal(0);
52 const cleanup = mount(element, { scrollPos });
53
54 Object.defineProperty(element, "scrollTop", { writable: true, value: 100 });
55 element.dispatchEvent(new Event("scroll"));
56 expect(scrollPos.get()).toBe(100);
57
58 cleanup();
59
60 Object.defineProperty(element, "scrollTop", { writable: true, value: 200 });
61 element.dispatchEvent(new Event("scroll"));
62 expect(scrollPos.get()).toBe(100);
63 });
64 });
65
66 describe("scrollTo mode", () => {
67 it("scrolls to element when signal matches element ID", () => {
68 const element = document.createElement("div");
69 element.id = "section1";
70 element.dataset.voltScroll = "scrollTo:targetId";
71
72 const scrollIntoViewMock = vi.fn();
73 element.scrollIntoView = scrollIntoViewMock;
74
75 const targetId = signal("");
76 mount(element, { targetId });
77
78 targetId.set("section1");
79
80 expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
81 });
82
83 it("scrolls to element when signal matches #elementId format", () => {
84 const element = document.createElement("div");
85 element.id = "section2";
86 element.dataset.voltScroll = "scrollTo:targetId";
87
88 const scrollIntoViewMock = vi.fn();
89 element.scrollIntoView = scrollIntoViewMock;
90
91 const targetId = signal("");
92 mount(element, { targetId });
93
94 targetId.set("#section2");
95
96 expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
97 });
98
99 it("does not scroll if signal does not match element ID", () => {
100 const element = document.createElement("div");
101 element.id = "section1";
102 element.dataset.voltScroll = "scrollTo:targetId";
103
104 const scrollIntoViewMock = vi.fn();
105 element.scrollIntoView = scrollIntoViewMock;
106
107 const targetId = signal("otherSection");
108 mount(element, { targetId });
109 expect(scrollIntoViewMock).not.toHaveBeenCalled();
110 });
111
112 it("scrolls on initial mount if signal already matches", () => {
113 const element = document.createElement("div");
114 element.id = "section1";
115 element.dataset.voltScroll = "scrollTo:targetId";
116
117 const scrollIntoViewMock = vi.fn();
118 element.scrollIntoView = scrollIntoViewMock;
119
120 const targetId = signal("section1");
121 mount(element, { targetId });
122 expect(scrollIntoViewMock).toHaveBeenCalledOnce();
123 });
124 });
125
126 describe("spy mode", () => {
127 it("updates signal when element enters viewport", () => {
128 const element = document.createElement("div");
129 element.dataset.voltScroll = "spy:isVisible";
130
131 const isVisible = signal(false);
132 let observerCallback!: IntersectionObserverCallback;
133 const mockObserver = {
134 observe: vi.fn(),
135 disconnect: vi.fn(),
136 unobserve: vi.fn(),
137 takeRecords: vi.fn(),
138 root: null,
139 rootMargin: "",
140 thresholds: [],
141 };
142
143 (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => {
144 observerCallback = callback;
145 return mockObserver;
146 }) as unknown as typeof IntersectionObserver;
147
148 mount(element, { isVisible });
149 expect(mockObserver.observe).toHaveBeenCalledWith(element);
150 observerCallback(
151 [{ isIntersecting: true, target: element } as unknown as IntersectionObserverEntry],
152 mockObserver as IntersectionObserver,
153 );
154
155 expect(isVisible.get()).toBe(true);
156 observerCallback(
157 [{ isIntersecting: false, target: element } as unknown as IntersectionObserverEntry],
158 mockObserver as IntersectionObserver,
159 );
160 expect(isVisible.get()).toBe(false);
161 });
162
163 it("disconnects observer on cleanup", () => {
164 const element = document.createElement("div");
165 element.dataset.voltScroll = "spy:isVisible";
166
167 const isVisible = signal(false);
168 const mockObserver = {
169 observe: vi.fn(),
170 disconnect: vi.fn(),
171 unobserve: vi.fn(),
172 takeRecords: vi.fn(),
173 root: null,
174 rootMargin: "",
175 thresholds: [],
176 };
177
178 (globalThis as typeof globalThis).IntersectionObserver = vi.fn(() =>
179 mockObserver
180 ) as unknown as typeof IntersectionObserver;
181
182 const cleanup = mount(element, { isVisible });
183 cleanup();
184 expect(mockObserver.disconnect).toHaveBeenCalled();
185 });
186 });
187
188 describe("smooth mode", () => {
189 it("applies smooth scroll behavior when signal is true", () => {
190 const element = document.createElement("div");
191 element.dataset.voltScroll = "smooth:smoothScroll";
192
193 const smoothScroll = signal(true);
194 mount(element, { smoothScroll });
195 expect(element.style.scrollBehavior).toBe("smooth");
196 });
197
198 it("applies smooth scroll behavior when signal is 'smooth'", () => {
199 const element = document.createElement("div");
200 element.dataset.voltScroll = "smooth:smoothScroll";
201
202 const smoothScroll = signal("smooth");
203 mount(element, { smoothScroll });
204 expect(element.style.scrollBehavior).toBe("smooth");
205 });
206
207 it("applies auto scroll behavior when signal is false", () => {
208 const element = document.createElement("div");
209 element.dataset.voltScroll = "smooth:smoothScroll";
210
211 const smoothScroll = signal(false);
212 mount(element, { smoothScroll });
213
214 expect(element.style.scrollBehavior).toBe("auto");
215 });
216
217 it("applies auto scroll behavior when signal is 'auto'", () => {
218 const element = document.createElement("div");
219 element.dataset.voltScroll = "smooth:smoothScroll";
220
221 const smoothScroll = signal("auto");
222 mount(element, { smoothScroll });
223
224 expect(element.style.scrollBehavior).toBe("auto");
225 });
226
227 it("updates scroll behavior when signal changes", () => {
228 const element = document.createElement("div");
229 element.dataset.voltScroll = "smooth:smoothScroll";
230
231 const smoothScroll = signal(false);
232 mount(element, { smoothScroll });
233
234 expect(element.style.scrollBehavior).toBe("auto");
235
236 smoothScroll.set(true);
237 expect(element.style.scrollBehavior).toBe("smooth");
238
239 smoothScroll.set(false);
240 expect(element.style.scrollBehavior).toBe("auto");
241 });
242
243 it("resets scroll behavior on cleanup", () => {
244 const element = document.createElement("div");
245 element.dataset.voltScroll = "smooth:smoothScroll";
246
247 const smoothScroll = signal(true);
248 const cleanup = mount(element, { smoothScroll });
249 expect(element.style.scrollBehavior).toBe("smooth");
250 cleanup();
251 expect(element.style.scrollBehavior).toBe("");
252 });
253 });
254
255 describe("history mode", () => {
256 it("saves scroll position on volt:navigate event", () => {
257 const element = document.createElement("div");
258 element.dataset.voltScroll = "history";
259 Object.defineProperty(element, "scrollTop", { writable: true, value: 150 });
260
261 mount(element, {});
262 globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } }));
263 expect(element.dataset.voltScroll).toBe("history");
264 });
265
266 it("restores scroll position on volt:popstate event", async () => {
267 const element = document.createElement("div");
268 element.dataset.voltScroll = "history";
269
270 let currentScrollTop = 0;
271 Object.defineProperty(element, "scrollTop", {
272 get() {
273 return currentScrollTop;
274 },
275 set(value) {
276 currentScrollTop = value;
277 },
278 configurable: true,
279 });
280
281 mount(element, {});
282
283 globalThis.history.replaceState({}, "", "/page1");
284 element.scrollTop = 300;
285 globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } }));
286
287 globalThis.history.replaceState({}, "", "/page2");
288 element.scrollTop = 0;
289 globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page2" } }));
290
291 globalThis.history.replaceState({}, "", "/page1");
292 element.scrollTop = 0;
293 globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
294
295 await new Promise((resolve) => requestAnimationFrame(resolve));
296
297 expect(element.scrollTop).toBe(300);
298 });
299
300 it("handles multiple navigation cycles correctly", async () => {
301 const element = document.createElement("div");
302 element.dataset.voltScroll = "history";
303
304 let currentScrollTop = 0;
305 Object.defineProperty(element, "scrollTop", {
306 get() {
307 return currentScrollTop;
308 },
309 set(value) {
310 currentScrollTop = value;
311 },
312 configurable: true,
313 });
314
315 mount(element, {});
316
317 globalThis.history.replaceState({}, "", "/page1");
318 element.scrollTop = 100;
319 globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } }));
320
321 globalThis.history.replaceState({}, "", "/page2");
322 element.scrollTop = 200;
323 globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page2" } }));
324
325 globalThis.history.replaceState({}, "", "/page3");
326 element.scrollTop = 300;
327 globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page3" } }));
328
329 globalThis.history.replaceState({}, "", "/page2");
330 element.scrollTop = 0;
331 globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
332 await new Promise((resolve) => requestAnimationFrame(resolve));
333 expect(element.scrollTop).toBe(200);
334
335 globalThis.history.replaceState({}, "", "/page1");
336 element.scrollTop = 0;
337 globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
338 await new Promise((resolve) => requestAnimationFrame(resolve));
339 expect(element.scrollTop).toBe(100);
340 });
341
342 it("cleans up event listeners on unmount", () => {
343 const element = document.createElement("div");
344 element.dataset.voltScroll = "history";
345
346 const cleanup = mount(element, {});
347
348 Object.defineProperty(element, "scrollTop", { writable: true, value: 100 });
349 globalThis.dispatchEvent(new CustomEvent("volt:navigate"));
350
351 cleanup();
352
353 Object.defineProperty(element, "scrollTop", { writable: true, value: 200 });
354 globalThis.dispatchEvent(new CustomEvent("volt:navigate"));
355
356 expect(element.scrollTop).toBe(200);
357 });
358
359 it("does not restore scroll position if not previously saved", async () => {
360 const element = document.createElement("div");
361 element.dataset.voltScroll = "history";
362 Object.defineProperty(element, "scrollTop", { writable: true, value: 50 });
363
364 mount(element, {});
365
366 globalThis.history.replaceState({}, "", "/new-page");
367 globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
368
369 await new Promise((resolve) => requestAnimationFrame(resolve));
370
371 expect(element.scrollTop).toBe(50);
372 });
373 });
374
375 describe("error handling", () => {
376 it("logs error for invalid binding format", () => {
377 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
378 const element = document.createElement("div");
379 element.dataset.voltScroll = "invalidformat";
380
381 mount(element, {});
382 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid scroll binding"));
383 errorSpy.mockRestore();
384 });
385
386 it("logs error for unknown scroll mode", () => {
387 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
388 const element = document.createElement("div");
389 element.dataset.voltScroll = "unknown:signal";
390
391 mount(element, {});
392 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown scroll mode: \"unknown\""));
393 errorSpy.mockRestore();
394 });
395
396 it("logs error when signal not found", () => {
397 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
398 const element = document.createElement("div");
399 element.dataset.voltScroll = "restore:nonexistent";
400
401 mount(element, {});
402 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found"));
403 errorSpy.mockRestore();
404 });
405 });
406});