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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425import { signal } from "$core/signal";import { getAnimation, getRegisteredAnimations, hasAnimation, registerAnimation, shiftPlugin, unregisterAnimation,} from "$plugins/shift";import type { AnimationPreset, PluginContext } from "$types/volt";import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("Shift Plugin", () => { let container: HTMLDivElement; let element: HTMLElement; let mockContext: PluginContext; let cleanups: Array<() => void>; let onMountCallbacks: Array<() => void>;
beforeEach(() => { container = document.createElement("div"); element = document.createElement("div"); element.textContent = "Test Content"; container.append(element); document.body.append(container);
cleanups = []; onMountCallbacks = [];
mockContext = { element, scope: {}, addCleanup: (fn) => { cleanups.push(fn); }, findSignal: vi.fn(), evaluate: vi.fn(), lifecycle: { onMount: (cb) => { onMountCallbacks.push(cb); cb(); }, onUnmount: vi.fn(), beforeBinding: vi.fn(), afterBinding: vi.fn(), }, };
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false });
element.style.animation = ""; });
afterEach(() => { for (const cleanup of cleanups) { cleanup(); } cleanups = []; onMountCallbacks = []; container.remove(); vi.restoreAllMocks(); });
describe("Animation Registry", () => { it("should have built-in animation presets", () => { expect(hasAnimation("bounce")).toBe(true); expect(hasAnimation("shake")).toBe(true); expect(hasAnimation("pulse")).toBe(true); expect(hasAnimation("spin")).toBe(true); expect(hasAnimation("flash")).toBe(true); });
it("should register custom animation", () => { const customAnimation: AnimationPreset = { keyframes: [{ offset: 0, transform: "scale(1)" }, { offset: 1, transform: "scale(1.5)" }], duration: 500, iterations: 1, timing: "ease", };
registerAnimation("custom", customAnimation); expect(hasAnimation("custom")).toBe(true); expect(getAnimation("custom")).toEqual(customAnimation); });
it("should unregister custom animation", () => { const customAnimation: AnimationPreset = { keyframes: [{ offset: 0, opacity: "1" }, { offset: 1, opacity: "0" }], duration: 300, iterations: 1, timing: "linear", };
registerAnimation("temp", customAnimation); expect(hasAnimation("temp")).toBe(true);
const result = unregisterAnimation("temp"); expect(result).toBe(true); expect(hasAnimation("temp")).toBe(false); });
it("should not unregister built-in animation", () => { const result = unregisterAnimation("bounce"); expect(result).toBe(false); expect(hasAnimation("bounce")).toBe(true); });
it("should get all registered animations", () => { const animations = getRegisteredAnimations(); expect(animations).toContain("bounce"); expect(animations).toContain("shake"); expect(animations).toContain("pulse"); expect(animations).toContain("spin"); expect(animations).toContain("flash"); });
it("should warn when overriding built-in animation", () => { const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const customAnimation: AnimationPreset = { keyframes: [{ offset: 0, opacity: "1" }], duration: 100, iterations: 1, timing: "ease", };
registerAnimation("bounce", customAnimation); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining("Overriding built-in animation preset: \"bounce\""), );
consoleSpy.mockRestore(); }); });
describe("Basic Animation Application", () => { it("should apply animation on mount", async () => { shiftPlugin(mockContext, "bounce");
await vi.waitFor(() => { expect(element.style.animationName).toMatch(/^volt-shift-/); }); });
it("should use default duration and iterations", async () => { shiftPlugin(mockContext, "bounce");
await vi.waitFor(() => { expect(element.style.animationDuration).toBe("100ms"); expect(element.style.animationIterationCount).toBe("1"); }); });
it("should apply custom duration", async () => { shiftPlugin(mockContext, "bounce.1000");
await vi.waitFor(() => { expect(element.style.animationDuration).toBe("1000ms"); }); });
it("should apply custom duration and iterations", async () => { shiftPlugin(mockContext, "bounce.500.3");
await vi.waitFor(() => { expect(element.style.animationDuration).toBe("500ms"); expect(element.style.animationIterationCount).toBe("3"); }); });
it("should handle unknown animation preset", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
shiftPlugin(mockContext, "unknown");
expect(element.style.animationName).toBe(""); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown animation preset: \"unknown\""));
consoleSpy.mockRestore(); });
it("should handle invalid shift value", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
shiftPlugin(mockContext, "");
expect(element.style.animationName).toBe(""); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid shift value"));
consoleSpy.mockRestore(); });
it("should work when Web Animations API is unavailable", async () => { // @ts-expect-error mutate for test element.animate = undefined;
shiftPlugin(mockContext, "bounce");
await vi.waitFor(() => { expect(element.style.animationName).toMatch(/^volt-shift-/); }); });
it("should normalize inline elements for transform animations", async () => { const span = document.createElement("span"); span.textContent = "⚙️"; container.append(span);
const context: PluginContext = { ...mockContext, element: span };
shiftPlugin(context, "spin");
await vi.waitFor(() => { expect(span.style.display).toBe("inline-block"); expect(span.dataset.voltShiftDisplayManaged).toBe("infinite"); expect(span.dataset.voltShiftRuns).toBe("1"); expect(span.style.transformOrigin).toBe("center center"); }); }); });
describe("Signal-Triggered Animations", () => { it("should trigger animation when signal changes to truthy", () => { const triggerSignal = signal(false); mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce");
expect(element.dataset.voltShiftRuns ?? "0").toBe("0");
triggerSignal.set(true);
expect(element.dataset.voltShiftRuns).toBe("1"); });
it("should not trigger animation when signal stays truthy", async () => { const triggerSignal = signal(true); mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce");
await vi.waitFor(() => { expect(element.dataset.voltShiftRuns).toBe("1"); });
triggerSignal.set(true);
expect(element.dataset.voltShiftRuns).toBe("1"); });
it("should trigger animation on initial mount if signal is truthy", async () => { const triggerSignal = signal(true); mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce");
await vi.waitFor(() => { expect(element.dataset.voltShiftRuns).toBe("1"); }); });
it("should handle signal not found", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); mockContext.findSignal = vi.fn().mockReturnValue(void 0);
shiftPlugin(mockContext, "missing:bounce");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"missing\" not found")); consoleSpy.mockRestore(); });
it("should support custom duration and iterations with signal trigger", () => { const triggerSignal = signal(false); mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce.800.2");
triggerSignal.set(true);
expect(element.dataset.voltShiftRuns).toBe("1"); expect(element.style.animationDuration).toBe("800ms"); expect(element.style.animationIterationCount).toBe("2"); });
it("should stop infinite animations when signal becomes falsy", async () => { const spinSignal = signal(true); mockContext.findSignal = vi.fn().mockReturnValue(spinSignal);
shiftPlugin(mockContext, "spin:spin");
await vi.waitFor(() => { expect(element.style.animationName).toMatch(/^volt-shift-/); expect(element.style.animationIterationCount).toBe("infinite"); });
spinSignal.set(false);
expect(element.style.animationName).toBe(""); expect(element.style.animationIterationCount).toBe(""); });
it("should not stop finite animations when signal becomes falsy", async () => { const triggerSignal = signal(true); mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce");
await vi.waitFor(() => { expect(element.style.animationName).toMatch(/^volt-shift-/); });
triggerSignal.set(false);
expect(element.style.animationName).toMatch(/^volt-shift-/); });
it("should restart infinite animation when signal toggles", async () => { const spinSignal = signal(true); mockContext.findSignal = vi.fn().mockReturnValue(spinSignal);
shiftPlugin(mockContext, "spin:spin");
await vi.waitFor(() => { expect(element.dataset.voltShiftRuns).toBe("1"); });
spinSignal.set(false); expect(element.style.animationName).toBe("");
spinSignal.set(true);
expect(element.dataset.voltShiftRuns).toBe("2"); }); });
describe("Accessibility", () => { it("should respect prefers-reduced-motion", () => { globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true });
shiftPlugin(mockContext, "bounce");
expect(element.style.animationName).toBe(""); });
it("should not animate when prefers-reduced-motion is active and signal triggers", () => { globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true });
const triggerSignal = signal(false); mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce");
triggerSignal.set(true);
expect(element.style.animationName).toBe(""); }); });
describe("Animation Cleanup", () => { it("should clear inline animation after it completes", async () => { shiftPlugin(mockContext, "bounce");
await vi.waitFor(() => { expect(element.style.animationName).toMatch(/^volt-shift-/); });
await new Promise((resolve) => setTimeout(resolve, 150));
expect(element.style.animationName).toBe(""); expect(element.style.animationFillMode).toBe(""); });
it("should cleanup signal subscription", () => { const triggerSignal = signal(false); const unsubscribe = vi.fn(); triggerSignal.subscribe = vi.fn().mockReturnValue(unsubscribe);
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce");
expect(cleanups).toHaveLength(1);
cleanups[0]();
expect(unsubscribe).toHaveBeenCalled(); }); });
describe("Built-in Animations", () => { it("should have bounce animation with correct keyframes", () => { const bounce = getAnimation("bounce"); expect(bounce).toBeDefined(); expect(bounce?.keyframes.length).toBeGreaterThan(0); expect(bounce?.duration).toBe(100); expect(bounce?.iterations).toBe(1); });
it("should have shake animation with correct keyframes", () => { const shake = getAnimation("shake"); expect(shake).toBeDefined(); expect(shake?.keyframes.length).toBeGreaterThan(0); expect(shake?.duration).toBe(500); });
it("should have pulse animation with infinite iterations", () => { const pulse = getAnimation("pulse"); expect(pulse).toBeDefined(); expect(pulse?.iterations).toBe(Number.POSITIVE_INFINITY); });
it("should have spin animation with infinite iterations", () => { const spin = getAnimation("spin"); expect(spin).toBeDefined(); expect(spin?.iterations).toBe(Number.POSITIVE_INFINITY); expect(spin?.timing).toBe("linear"); });
it("should have flash animation", () => { const flash = getAnimation("flash"); expect(flash).toBeDefined(); expect(flash?.duration).toBe(1000); }); });});