Something went wrong. Try again.
a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt
hypermedia frontend signals
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407import { mount } from "$core/binder";import { registerPlugin } from "$core/plugin";import { signal } from "$core/signal";import { scrollPlugin } from "$plugins/scroll";import { beforeEach, describe, expect, it, vi } from "vitest";
describe("scroll plugin", () => { beforeEach(() => { registerPlugin("scroll", scrollPlugin); });
describe("restore mode", () => { it("restores scroll position from signal on mount", () => { const element = document.createElement("div"); element.dataset.voltScroll = "restore:scrollPos"; Object.defineProperty(element, "scrollTop", { writable: true, value: 0 });
const scrollPos = signal(250); mount(element, { scrollPos }); expect(element.scrollTop).toBe(250); });
it("saves scroll position to signal on scroll", () => { const element = document.createElement("div"); element.dataset.voltScroll = "restore:scrollPos";
const scrollPos = signal(0); mount(element, { scrollPos });
Object.defineProperty(element, "scrollTop", { writable: true, value: 100 });
element.dispatchEvent(new Event("scroll"));
expect(scrollPos.get()).toBe(100); });
it("does not restore if signal value is not a number", () => { const element = document.createElement("div"); element.dataset.voltScroll = "restore:scrollPos"; Object.defineProperty(element, "scrollTop", { writable: true, value: 0 });
const scrollPos = signal("not a number" as unknown as number); mount(element, { scrollPos }); expect(element.scrollTop).toBe(0); });
it("cleans up scroll listener on unmount", () => { const element = document.createElement("div"); element.dataset.voltScroll = "restore:scrollPos";
const scrollPos = signal(0); const cleanup = mount(element, { scrollPos });
Object.defineProperty(element, "scrollTop", { writable: true, value: 100 }); element.dispatchEvent(new Event("scroll")); expect(scrollPos.get()).toBe(100);
cleanup();
Object.defineProperty(element, "scrollTop", { writable: true, value: 200 }); element.dispatchEvent(new Event("scroll")); expect(scrollPos.get()).toBe(100); }); });
describe("scrollTo mode", () => { it("scrolls to element when signal matches element ID", () => { const element = document.createElement("div"); element.id = "section1"; element.dataset.voltScroll = "scrollTo:targetId";
const scrollIntoViewMock = vi.fn(); element.scrollIntoView = scrollIntoViewMock;
const targetId = signal(""); mount(element, { targetId });
targetId.set("section1");
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "start" }); });
it("scrolls to element when signal matches #elementId format", () => { const element = document.createElement("div"); element.id = "section2"; element.dataset.voltScroll = "scrollTo:targetId";
const scrollIntoViewMock = vi.fn(); element.scrollIntoView = scrollIntoViewMock;
const targetId = signal(""); mount(element, { targetId });
targetId.set("#section2");
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "start" }); });
it("does not scroll if signal does not match element ID", () => { const element = document.createElement("div"); element.id = "section1"; element.dataset.voltScroll = "scrollTo:targetId";
const scrollIntoViewMock = vi.fn(); element.scrollIntoView = scrollIntoViewMock;
const targetId = signal("otherSection"); mount(element, { targetId }); expect(scrollIntoViewMock).not.toHaveBeenCalled(); });
it("scrolls on initial mount if signal already matches", () => { const element = document.createElement("div"); element.id = "section1"; element.dataset.voltScroll = "scrollTo:targetId";
const scrollIntoViewMock = vi.fn(); element.scrollIntoView = scrollIntoViewMock;
const targetId = signal("section1"); mount(element, { targetId }); expect(scrollIntoViewMock).toHaveBeenCalledOnce(); }); });
describe("spy mode", () => { it("updates signal when element enters viewport", () => { const element = document.createElement("div"); element.dataset.voltScroll = "spy:isVisible";
const isVisible = signal(false); let observerCallback!: IntersectionObserverCallback; const mockObserver = { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn(), takeRecords: vi.fn(), root: null, rootMargin: "", thresholds: [], };
(globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => { observerCallback = callback; return mockObserver; }) as unknown as typeof IntersectionObserver;
mount(element, { isVisible }); expect(mockObserver.observe).toHaveBeenCalledWith(element); observerCallback( [{ isIntersecting: true, target: element } as unknown as IntersectionObserverEntry], mockObserver as IntersectionObserver, );
expect(isVisible.get()).toBe(true); observerCallback( [{ isIntersecting: false, target: element } as unknown as IntersectionObserverEntry], mockObserver as IntersectionObserver, ); expect(isVisible.get()).toBe(false); });
it("disconnects observer on cleanup", () => { const element = document.createElement("div"); element.dataset.voltScroll = "spy:isVisible";
const isVisible = signal(false); const mockObserver = { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn(), takeRecords: vi.fn(), root: null, rootMargin: "", thresholds: [], };
(globalThis as typeof globalThis).IntersectionObserver = vi.fn(() => mockObserver ) as unknown as typeof IntersectionObserver;
const cleanup = mount(element, { isVisible }); cleanup(); expect(mockObserver.disconnect).toHaveBeenCalled(); }); });
describe("smooth mode", () => { it("applies smooth scroll behavior when signal is true", () => { const element = document.createElement("div"); element.dataset.voltScroll = "smooth:smoothScroll";
const smoothScroll = signal(true); mount(element, { smoothScroll }); expect(element.style.scrollBehavior).toBe("smooth"); });
it("applies smooth scroll behavior when signal is 'smooth'", () => { const element = document.createElement("div"); element.dataset.voltScroll = "smooth:smoothScroll";
const smoothScroll = signal("smooth"); mount(element, { smoothScroll }); expect(element.style.scrollBehavior).toBe("smooth"); });
it("applies auto scroll behavior when signal is false", () => { const element = document.createElement("div"); element.dataset.voltScroll = "smooth:smoothScroll";
const smoothScroll = signal(false); mount(element, { smoothScroll });
expect(element.style.scrollBehavior).toBe("auto"); });
it("applies auto scroll behavior when signal is 'auto'", () => { const element = document.createElement("div"); element.dataset.voltScroll = "smooth:smoothScroll";
const smoothScroll = signal("auto"); mount(element, { smoothScroll });
expect(element.style.scrollBehavior).toBe("auto"); });
it("updates scroll behavior when signal changes", () => { const element = document.createElement("div"); element.dataset.voltScroll = "smooth:smoothScroll";
const smoothScroll = signal(false); mount(element, { smoothScroll });
expect(element.style.scrollBehavior).toBe("auto");
smoothScroll.set(true); expect(element.style.scrollBehavior).toBe("smooth");
smoothScroll.set(false); expect(element.style.scrollBehavior).toBe("auto"); });
it("resets scroll behavior on cleanup", () => { const element = document.createElement("div"); element.dataset.voltScroll = "smooth:smoothScroll";
const smoothScroll = signal(true); const cleanup = mount(element, { smoothScroll }); expect(element.style.scrollBehavior).toBe("smooth"); cleanup(); expect(element.style.scrollBehavior).toBe(""); }); });
describe("history mode", () => { it("saves scroll position on volt:navigate event", () => { const element = document.createElement("div"); element.dataset.voltScroll = "history"; Object.defineProperty(element, "scrollTop", { writable: true, value: 150 });
mount(element, {}); globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } })); expect(element.dataset.voltScroll).toBe("history"); });
it("restores scroll position on volt:popstate event", async () => { const element = document.createElement("div"); element.dataset.voltScroll = "history";
let currentScrollTop = 0; Object.defineProperty(element, "scrollTop", { get() { return currentScrollTop; }, set(value) { currentScrollTop = value; }, configurable: true, });
mount(element, {});
globalThis.history.replaceState({}, "", "/page1"); element.scrollTop = 300; globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } }));
globalThis.history.replaceState({}, "", "/page2"); element.scrollTop = 0; globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page2" } }));
globalThis.history.replaceState({}, "", "/page1"); element.scrollTop = 0; globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(element.scrollTop).toBe(300); });
it("handles multiple navigation cycles correctly", async () => { const element = document.createElement("div"); element.dataset.voltScroll = "history";
let currentScrollTop = 0; Object.defineProperty(element, "scrollTop", { get() { return currentScrollTop; }, set(value) { currentScrollTop = value; }, configurable: true, });
mount(element, {});
globalThis.history.replaceState({}, "", "/page1"); element.scrollTop = 100; globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } }));
globalThis.history.replaceState({}, "", "/page2"); element.scrollTop = 200; globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page2" } }));
globalThis.history.replaceState({}, "", "/page3"); element.scrollTop = 300; globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page3" } }));
globalThis.history.replaceState({}, "", "/page2"); element.scrollTop = 0; globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } })); await new Promise((resolve) => requestAnimationFrame(resolve)); expect(element.scrollTop).toBe(200);
globalThis.history.replaceState({}, "", "/page1"); element.scrollTop = 0; globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } })); await new Promise((resolve) => requestAnimationFrame(resolve)); expect(element.scrollTop).toBe(100); });
it("cleans up event listeners on unmount", () => { const element = document.createElement("div"); element.dataset.voltScroll = "history";
const cleanup = mount(element, {});
Object.defineProperty(element, "scrollTop", { writable: true, value: 100 }); globalThis.dispatchEvent(new CustomEvent("volt:navigate"));
cleanup();
Object.defineProperty(element, "scrollTop", { writable: true, value: 200 }); globalThis.dispatchEvent(new CustomEvent("volt:navigate"));
expect(element.scrollTop).toBe(200); });
it("does not restore scroll position if not previously saved", async () => { const element = document.createElement("div"); element.dataset.voltScroll = "history"; Object.defineProperty(element, "scrollTop", { writable: true, value: 50 });
mount(element, {});
globalThis.history.replaceState({}, "", "/new-page"); globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(element.scrollTop).toBe(50); }); });
describe("error handling", () => { it("logs error for invalid binding format", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const element = document.createElement("div"); element.dataset.voltScroll = "invalidformat";
mount(element, {}); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid scroll binding")); errorSpy.mockRestore(); });
it("logs error for unknown scroll mode", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const element = document.createElement("div"); element.dataset.voltScroll = "unknown:signal";
mount(element, {}); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown scroll mode: \"unknown\"")); errorSpy.mockRestore(); });
it("logs error when signal not found", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const element = document.createElement("div"); element.dataset.voltScroll = "restore:nonexistent";
mount(element, {}); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found")); errorSpy.mockRestore(); }); });});