a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 14 kB view raw
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});