diff --git a/.changeset/fancy-toys-mate.md b/.changeset/fancy-toys-mate.md new file mode 100644 index 0000000..5c842b3 --- /dev/null +++ b/.changeset/fancy-toys-mate.md @@ -0,0 +1,5 @@ +--- +"motion-on-scroll": patch +--- + +Fix potential element flash on page resize. Also add additional tests for verification. diff --git a/.changeset/sad-lights-repeat.md b/.changeset/sad-lights-repeat.md new file mode 100644 index 0000000..b3f4bf7 --- /dev/null +++ b/.changeset/sad-lights-repeat.md @@ -0,0 +1,5 @@ +--- +"motion-on-scroll": patch +--- + +Fix setting units in init function when not explicitly setting duration and delay. Also add additional tests for verification. diff --git a/README.md b/README.md index ddd5fdc..d0fd261 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![NPM version](https://img.shields.io/npm/v/motion-on-scroll.svg?style=flat)](https://npmjs.org/package/motion-on-scroll) [![NPM downloads](https://img.shields.io/npm/dm/motion-on-scroll.svg?style=flat)](https://npmjs.org/package/motion-on-scroll) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Twitter Follow](https://img.shields.io/twitter/follow/webreaper.svg?style=social)](https://twitter.com/webreaper) -## Note: this is currently in beta - there are still various items to be ironed out. Feel free to try it though :) +Looking for the main package? Go to [motion-on-scroll](/packages/motion-on-scroll/). + +## Note: this is currently in beta. Feel free to try it though :) Effortless, AOS-compatible scroll animations powered by [Motion](https://motion.dev). diff --git a/apps/docs/src/components/MosDemo.astro b/apps/docs/src/components/MosDemo.astro index a6dfd2e..115d777 100644 --- a/apps/docs/src/components/MosDemo.astro +++ b/apps/docs/src/components/MosDemo.astro @@ -1,4 +1,45 @@ --- +import { Code } from "@astrojs/starlight/components"; + +// Code examples for custom animations +const keyframesCode = `MOS.registerKeyframes("my-custom-keyframes", { + opacity: [0, 1], + rotate: [0, 360], +});`; + +const keyframesHtml = `
+ my-custom-keyframes +
`; + +const animationCode = `MOS.registerAnimation("bouncy", (el, opts) => + animate( + el, + { opacity: [0, 1], scale: [0.4, 1] }, + { + type: "spring", + bounce: 0.5, + duration: opts.timeUnits === "s" ? opts.duration : opts.duration / 1000, + delay: opts.timeUnits === "s" ? opts.delay : opts.delay / 1000, + }, + ), +);`; + +const animationHtml = `
+ bouncy animation +
`; + +const bouncyEasingCode = `MOS.registerEasing("bouncy", [0.68, -0.55, 0.265, 1.55]);`; + +const bouncyEasingHtml = `
+ fade-up w/ bouncy easing +
`; + +const dramaticEasingCode = `MOS.registerEasing("dramatic", "cubic-bezier(0.25, 0.46, 0.45, 0.94)");`; + +const dramaticEasingHtml = `
+ fade-up w/ dramatic easing +
`; + const animationSections = [ { title: "Fade Animations", @@ -67,10 +108,10 @@ const animationSections = [ }`} >
- {animation} + data-mos="{animation}"
); @@ -82,7 +123,7 @@ const animationSections = [ } -
+

@@ -91,43 +132,83 @@ const animationSections = [
-
-
- my-custom-keyframes +
+
+
+ +
+
+ +
+
+
+
+ my-custom-keyframes +
-
-
- bouncy animation +
+
+
+ bouncy animation +
+
+
+
+ +
+
+ +
-
-
- fade-up w/ bouncy easing +
+
+
+ +
+
+ +
+
+
+
+ fade-up w/ bouncy easing +
-
-
- fade-up w/ dramatic easing +
+
+
+ fade-up w/ dramatic easing +
+
+
+
+ +
+
+ +
@@ -169,6 +250,7 @@ const animationSections = [ duration: 800, offset: 50, once: false, + timeUnits: "ms", mirror: false, }); diff --git a/apps/docs/src/content/docs/getting-started/installation.mdx b/apps/docs/src/content/docs/getting-started/installation.mdx index e9ce254..844eccd 100644 --- a/apps/docs/src/content/docs/getting-started/installation.mdx +++ b/apps/docs/src/content/docs/getting-started/installation.mdx @@ -30,7 +30,7 @@ yarn add motion-on-scroll ## Minimal bootstrapping @@ -86,7 +86,7 @@ That’s it! Every element declaring a `data-mos` value is now animated when it ### CSS file -MOS ships with a small stylesheet that ensures elements are hidden upon initial page load. Import it from `motion-on-scroll/dist/mos.css` or copy the file into your own pipeline. The contents of the file are copied below for convenience. +MOS ships with a small stylesheet that ensures elements are hidden upon initial page load. Import it from `motion-on-scroll/dist/mos.css` or copy the file into your own pipeline. The contents of the file are below for convenience. ```css /* no pointer events until animation starts */ diff --git a/apps/docs/src/content/docs/reference/api.mdx b/apps/docs/src/content/docs/reference/api.mdx index 90519e3..a74c48a 100644 --- a/apps/docs/src/content/docs/reference/api.mdx +++ b/apps/docs/src/content/docs/reference/api.mdx @@ -29,7 +29,7 @@ MOS.init({ *Type*: `number` (pixels) *Default*: `120` -Additional distance (in pixels) before an element is considered *in view* (applied via `rootMargin`). +Additional distance (in pixels) before an element is considered *in view*. #### duration diff --git a/apps/docs/src/content/docs/reference/presets.mdx b/apps/docs/src/content/docs/reference/presets.mdx index 5bd348d..8e82b6c 100644 --- a/apps/docs/src/content/docs/reference/presets.mdx +++ b/apps/docs/src/content/docs/reference/presets.mdx @@ -5,7 +5,7 @@ description: Animation presets shipped with Motion-On-Scroll and how to extend t ## Available presets -All of the standard AOS presets are supported. +All of the standard AOS presets are supported. You can see them in action on the [homepage](/)! ### Fades diff --git a/packages/motion-on-scroll/README.md b/packages/motion-on-scroll/README.md index ddd5fdc..57d5aa7 100644 --- a/packages/motion-on-scroll/README.md +++ b/packages/motion-on-scroll/README.md @@ -2,7 +2,7 @@ [![NPM version](https://img.shields.io/npm/v/motion-on-scroll.svg?style=flat)](https://npmjs.org/package/motion-on-scroll) [![NPM downloads](https://img.shields.io/npm/dm/motion-on-scroll.svg?style=flat)](https://npmjs.org/package/motion-on-scroll) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Twitter Follow](https://img.shields.io/twitter/follow/webreaper.svg?style=social)](https://twitter.com/webreaper) -## Note: this is currently in beta - there are still various items to be ironed out. Feel free to try it though :) +## Note: this is currently in beta. Feel free to try it though :) Effortless, AOS-compatible scroll animations powered by [Motion](https://motion.dev). diff --git a/packages/motion-on-scroll/src/__tests__/index.spec.ts b/packages/motion-on-scroll/src/__tests__/index.spec.ts new file mode 100644 index 0000000..f77fa85 --- /dev/null +++ b/packages/motion-on-scroll/src/__tests__/index.spec.ts @@ -0,0 +1,618 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { DEFAULT_OPTIONS } from "../helpers/constants.js"; + +// Mock all dependencies +vi.mock("../helpers/animations.js", () => ({ + registerAnimation: vi.fn(), +})); + +vi.mock("../helpers/easing.js", () => ({ + registerEasing: vi.fn(), +})); + +vi.mock("../helpers/elements.js", () => ({ + clearAllElements: vi.fn(), + getMosElements: vi.fn(() => []), + prepareElements: vi.fn(), +})); + +vi.mock("../helpers/keyframes.js", () => ({ + registerKeyframes: vi.fn(), +})); + +vi.mock("../helpers/observer.js", () => ({ + startDomObserver: vi.fn(), +})); + +vi.mock("../helpers/scroll-handler.js", () => ({ + cleanupScrollHandler: vi.fn(), + ensureScrollHandlerActive: vi.fn(), + evaluateElementPositions: vi.fn(), + updateScrollHandlerDelays: vi.fn(), +})); + +vi.mock("../helpers/utils.js", () => ({ + debounce: vi.fn((fn) => fn), + isDisabled: vi.fn(() => false), + removeMosAttributes: vi.fn(), +})); + +// Import mocked functions +import { registerAnimation } from "../helpers/animations.js"; +import { registerEasing } from "../helpers/easing.js"; +import { clearAllElements, getMosElements, prepareElements } from "../helpers/elements.js"; +import { registerKeyframes } from "../helpers/keyframes.js"; +import { startDomObserver } from "../helpers/observer.js"; +import { + cleanupScrollHandler, + ensureScrollHandlerActive, + evaluateElementPositions, + updateScrollHandlerDelays, +} from "../helpers/scroll-handler.js"; +import { debounce, isDisabled, removeMosAttributes } from "../helpers/utils.js"; + +describe("index.ts - Main Entry Point", () => { + let mockElement1: HTMLElement; + let mockElement2: HTMLElement; + let MOS: any; + let init: any; + let refresh: any; + let refreshHard: any; + let handleLayoutChange: any; + let setupStartEventListener: any; + + beforeEach(async () => { + // Reset all mocks + vi.clearAllMocks(); + + // Create mock DOM elements + mockElement1 = document.createElement("div"); + mockElement1.setAttribute("data-mos", "fade"); + mockElement1.setAttribute("id", "element1"); + + mockElement2 = document.createElement("div"); + mockElement2.setAttribute("data-mos", "slide-up"); + mockElement2.setAttribute("id", "element2"); + + // Mock getMosElements to return our test elements + vi.mocked(getMosElements).mockReturnValue([mockElement1, mockElement2]); + + // Mock document.readyState since it's read-only + Object.defineProperty(document, "readyState", { + writable: true, + value: "loading", + }); + + // Mock global MutationObserver + global.MutationObserver = vi.fn(() => ({ + observe: vi.fn(), + disconnect: vi.fn(), + takeRecords: vi.fn(), + })) as any; + + // Reset modules and re-import to get fresh state + vi.resetModules(); + const indexModule = await import("../index.js"); + MOS = indexModule.MOS; + init = indexModule.init; + refresh = indexModule.refresh; + refreshHard = indexModule.refreshHard; + handleLayoutChange = indexModule.handleLayoutChange; + setupStartEventListener = indexModule.setupStartEventListener; + }); + + describe("MOS object export", () => { + it("should export MOS object with all required methods", () => { + expect(MOS).toBeDefined(); + expect(MOS.init).toBeDefined(); + expect(MOS.refresh).toBeDefined(); + expect(MOS.refreshHard).toBeDefined(); + expect(MOS.registerKeyframes).toBeDefined(); + expect(MOS.registerEasing).toBeDefined(); + expect(MOS.registerAnimation).toBeDefined(); + }); + + it("should have function exports matching MOS object", () => { + expect(typeof init).toBe("function"); + expect(typeof refresh).toBe("function"); + expect(typeof refreshHard).toBe("function"); + }); + }); + + describe("init()", () => { + it("should initialize with default options when no options provided", () => { + const result = init(); + + expect(getMosElements).toHaveBeenCalled(); + expect(result).toEqual([mockElement1, mockElement2]); + }); + + it("should merge provided options with defaults", () => { + const customOptions = { + duration: 800, + easing: "ease-in-out", + delay: 100, + }; + + init(customOptions); + + // Should still find elements + expect(getMosElements).toHaveBeenCalled(); + }); + + it("should handle time unit conversion on first init", () => { + const optionsWithSeconds = { + timeUnits: "s" as const, + }; + + init(optionsWithSeconds); + + expect(getMosElements).toHaveBeenCalled(); + }); + + it("should handle global disable by cleaning up elements", () => { + vi.mocked(isDisabled).mockReturnValue(true); + + const result = init(); + + // removeMosAttributes is called with forEach, so it gets (element, index, array) + expect(removeMosAttributes).toHaveBeenCalledWith(mockElement1, 0, [ + mockElement1, + mockElement2, + ]); + expect(removeMosAttributes).toHaveBeenCalledWith(mockElement2, 1, [ + mockElement1, + mockElement2, + ]); + expect(result).toEqual([]); + }); + + it("should start DOM observer when not disabled and MutationObserver exists", () => { + // Mock isDisabled to return false so we don't exit early + vi.mocked(isDisabled).mockReturnValue(false); + + init({ disableMutationObserver: false }); + + expect(startDomObserver).toHaveBeenCalled(); + }); + + it("should not start DOM observer when disabled", () => { + init({ disableMutationObserver: true }); + + expect(startDomObserver).not.toHaveBeenCalled(); + }); + + it("should not start DOM observer when MutationObserver is not supported", () => { + // Remove MutationObserver + const originalMutationObserver = global.MutationObserver; + delete (global as any).MutationObserver; + + init(); + + expect(startDomObserver).not.toHaveBeenCalled(); + + // Restore MutationObserver + global.MutationObserver = originalMutationObserver; + }); + + it("should refresh when called multiple times", () => { + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + // First init + init(); + expect(getMosElements).toHaveBeenCalledTimes(1); + + // Second init should trigger refresh + init({ duration: 600 }); + expect(getMosElements).toHaveBeenCalledTimes(2); + }); + }); + + describe("refresh()", () => { + it("should not activate library when shouldActivate is false and library is inactive", () => { + refresh(false); + + // Should not call any scroll handler functions when library is inactive + expect(updateScrollHandlerDelays).not.toHaveBeenCalled(); + expect(prepareElements).not.toHaveBeenCalled(); + expect(ensureScrollHandlerActive).not.toHaveBeenCalled(); + expect(evaluateElementPositions).not.toHaveBeenCalled(); + }); + + it("should activate library when shouldActivate is true", () => { + refresh(true); + + expect(updateScrollHandlerDelays).toHaveBeenCalled(); + expect(getMosElements).toHaveBeenCalled(); + expect(prepareElements).toHaveBeenCalledWith([mockElement1, mockElement2], DEFAULT_OPTIONS); + expect(ensureScrollHandlerActive).toHaveBeenCalled(); + expect(evaluateElementPositions).toHaveBeenCalled(); + }); + + it("should update scroll handler delays with library config", () => { + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + // First init to set library as active + init({ throttleDelay: 150 }); + + // Call refresh with shouldActivate=true to ensure library is active + refresh(true); + + expect(updateScrollHandlerDelays).toHaveBeenCalledWith(150); + }); + + it("should prepare elements with current library config", () => { + const customOptions = { duration: 800, delay: 200 }; + + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + // Init with custom options to set library config + init(customOptions); + + // Call refresh with shouldActivate=true to ensure library is active + refresh(true); + + expect(prepareElements).toHaveBeenCalledWith( + [mockElement1, mockElement2], + expect.objectContaining(customOptions), + ); + }); + }); + + describe("refreshHard()", () => { + it("should re-find elements and clear existing state", () => { + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + // Set up library as active first + init(); + + refreshHard(); + + expect(getMosElements).toHaveBeenCalledWith(true); // Force refresh + expect(clearAllElements).toHaveBeenCalled(); + expect(cleanupScrollHandler).toHaveBeenCalled(); + }); + + it("should handle global disable during hard refresh", () => { + vi.mocked(isDisabled).mockReturnValue(true); + + refreshHard(); + + // removeMosAttributes is called with forEach, so it gets (element, index, array) + expect(removeMosAttributes).toHaveBeenCalledWith(mockElement1, 0, [ + mockElement1, + mockElement2, + ]); + expect(removeMosAttributes).toHaveBeenCalledWith(mockElement2, 1, [ + mockElement1, + mockElement2, + ]); + + // When disabled, function returns early - cleanup functions are NOT called + expect(clearAllElements).not.toHaveBeenCalled(); + expect(cleanupScrollHandler).not.toHaveBeenCalled(); + }); + + it("should call refresh after cleanup when not disabled", () => { + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + refreshHard(); + + expect(clearAllElements).toHaveBeenCalled(); + expect(cleanupScrollHandler).toHaveBeenCalled(); + + // refresh() is called, but updateScrollHandlerDelays only happens if library is active + // Since we haven't activated the library, updateScrollHandlerDelays won't be called + expect(getMosElements).toHaveBeenCalled(); // This should be called by refresh + }); + }); + + describe("handleLayoutChange()", () => { + it("should evaluate element positions when library is active", () => { + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + // Activate library first + init(); + refresh(true); + + handleLayoutChange(); + + expect(evaluateElementPositions).toHaveBeenCalled(); + }); + + it("should not evaluate positions when library is inactive", () => { + handleLayoutChange(); + + expect(evaluateElementPositions).not.toHaveBeenCalled(); + }); + }); + + describe("setupStartEventListener()", () => { + beforeEach(() => { + // Mock addEventListener + vi.spyOn(window, "addEventListener"); + vi.spyOn(document, "addEventListener"); + }); + + it("should call refresh immediately when DOMContentLoaded already fired", async () => { + Object.defineProperty(document, "readyState", { + writable: true, + value: "interactive", + }); + + // Re-import to get fresh module state with new readyState + vi.resetModules(); + const { setupStartEventListener } = await import("../index.js"); + + setupStartEventListener(); + + // Should not add event listener since DOM is already ready + expect(document.addEventListener).not.toHaveBeenCalledWith( + "DOMContentLoaded", + expect.any(Function), + expect.any(Object), + ); + }); + + it("should call refresh immediately when load already fired", async () => { + Object.defineProperty(document, "readyState", { + writable: true, + value: "complete", + }); + + vi.resetModules(); + const { setupStartEventListener } = await import("../index.js"); + + setupStartEventListener(); + + // Should not add event listener since page is already loaded + expect(window.addEventListener).not.toHaveBeenCalledWith( + "load", + expect.any(Function), + expect.any(Object), + ); + }); + + it("should add window event listener for load event", () => { + Object.defineProperty(document, "readyState", { + writable: true, + value: "loading", + }); + + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + // Initialize with load event + init({ startEvent: "load" }); + + expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function), { + once: true, + }); + }); + + it("should add document event listener for DOMContentLoaded", () => { + Object.defineProperty(document, "readyState", { + writable: true, + value: "loading", + }); + + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + // Initialize with DOMContentLoaded (default) + init({ startEvent: "DOMContentLoaded" }); + + expect(document.addEventListener).toHaveBeenCalledWith( + "DOMContentLoaded", + expect.any(Function), + { once: true }, + ); + }); + + it("should add document event listener for custom events", () => { + Object.defineProperty(document, "readyState", { + writable: true, + value: "loading", + }); + + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + init({ startEvent: "custom-event" }); + + expect(document.addEventListener).toHaveBeenCalledWith("custom-event", expect.any(Function), { + once: true, + }); + }); + }); + + describe("Layout change event listeners", () => { + beforeEach(() => { + vi.spyOn(window, "addEventListener"); + }); + + it("should set up resize and orientation change listeners", () => { + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + init(); + + expect(window.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function)); + expect(window.addEventListener).toHaveBeenCalledWith( + "orientationchange", + expect.any(Function), + ); + }); + + it("should use debounced handler with configured delay", () => { + const customDebounceDelay = 200; + + // Mock isDisabled to return false + vi.mocked(isDisabled).mockReturnValue(false); + + init({ debounceDelay: customDebounceDelay }); + + expect(debounce).toHaveBeenCalledWith(expect.any(Function), customDebounceDelay); + }); + }); + + describe("Registration functions", () => { + it("should expose registerKeyframes function", () => { + const mockKeyframes = { "custom-fade": { from: { opacity: 0 }, to: { opacity: 1 } } }; + + MOS.registerKeyframes(mockKeyframes); + + expect(registerKeyframes).toHaveBeenCalledWith(mockKeyframes); + }); + + it("should expose registerEasing function", () => { + const mockEasing = { "custom-ease": "cubic-bezier(0.25, 0.46, 0.45, 0.94)" }; + + MOS.registerEasing(mockEasing); + + expect(registerEasing).toHaveBeenCalledWith(mockEasing); + }); + + it("should expose registerAnimation function", () => { + const mockAnimation = { + name: "custom-animation", + factory: () => ({ opacity: [0, 1] }), + }; + + MOS.registerAnimation(mockAnimation); + + expect(registerAnimation).toHaveBeenCalledWith(mockAnimation); + }); + }); + + describe("Time units adjustment", () => { + it("should convert default duration from ms to seconds when timeUnits is 's'", () => { + const options = { + timeUnits: "s" as const, + // Don't specify duration - should use converted default + }; + + init(options); + + // The conversion should happen internally + // Default duration (400ms) should become 0.4s + expect(getMosElements).toHaveBeenCalled(); + }); + + it("should convert default delay from ms to seconds when timeUnits is 's'", () => { + const options = { + timeUnits: "s" as const, + // Don't specify delay - should use converted default + }; + + init(options); + + expect(getMosElements).toHaveBeenCalled(); + }); + + it("should not convert when timeUnits is 'ms'", () => { + const options = { + timeUnits: "ms" as const, + }; + + init(options); + + expect(getMosElements).toHaveBeenCalled(); + }); + + it("should not convert on subsequent inits", () => { + // First init with seconds + init({ timeUnits: "s" }); + + // Second init should not trigger conversion again + init({ timeUnits: "s" }); + + expect(getMosElements).toHaveBeenCalledTimes(2); + }); + + it("should not convert when explicit duration is provided", () => { + const options = { + timeUnits: "s" as const, + duration: 2, // Explicit duration should not be converted + }; + + init(options); + + expect(getMosElements).toHaveBeenCalled(); + }); + + it("should not convert when explicit delay is provided", () => { + const options = { + timeUnits: "s" as const, + delay: 1, // Explicit delay should not be converted + }; + + init(options); + + expect(getMosElements).toHaveBeenCalled(); + }); + + it("should convert default duration and delay when timeUnits is 's' and no explicit values provided", () => { + // Mock isDisabled to return false so init completes + vi.mocked(isDisabled).mockReturnValue(false); + + // Mock prepareElements to capture the config that gets passed + let capturedConfig: any; + vi.mocked(prepareElements).mockImplementation((elements, config) => { + capturedConfig = config; + return []; // Return empty array to match MosElement[] return type + }); + + // Call init with timeUnits: "s" but no explicit duration or delay + init({ timeUnits: "s" }); + + // Trigger refresh to ensure prepareElements gets called with the config + refresh(true); + + // Verify that the config passed to prepareElements has converted defaults + expect(capturedConfig).toBeDefined(); + expect(capturedConfig.timeUnits).toBe("s"); + + // Default duration should be converted: 400ms -> 0.4s + expect(capturedConfig.duration).toBe(0.4); + + // Default delay should be converted: 0ms -> 0s + expect(capturedConfig.delay).toBe(0); + }); + + it("should not convert defaults when explicit duration and delay are provided with timeUnits 's'", () => { + // Mock isDisabled to return false so init completes + vi.mocked(isDisabled).mockReturnValue(false); + + // Mock prepareElements to capture the config that gets passed + let capturedConfig: any; + vi.mocked(prepareElements).mockImplementation((elements, config) => { + capturedConfig = config; + return []; // Return empty array to match MosElement[] return type + }); + + // Call init with timeUnits: "s" AND explicit duration/delay + init({ + timeUnits: "s", + duration: 1.5, // Explicit value should not be converted + delay: 0.2, // Explicit value should not be converted + }); + + // Trigger refresh to ensure prepareElements gets called with the config + refresh(true); + + // Verify that the explicit values are preserved (not converted) + expect(capturedConfig).toBeDefined(); + expect(capturedConfig.timeUnits).toBe("s"); + expect(capturedConfig.duration).toBe(1.5); // Should remain as provided + expect(capturedConfig.delay).toBe(0.2); // Should remain as provided + }); + }); +}); diff --git a/packages/motion-on-scroll/src/__tests__/scroll-handler.spec.ts b/packages/motion-on-scroll/src/__tests__/scroll-handler.spec.ts index 9f6be70..9770435 100644 --- a/packages/motion-on-scroll/src/__tests__/scroll-handler.spec.ts +++ b/packages/motion-on-scroll/src/__tests__/scroll-handler.spec.ts @@ -287,6 +287,37 @@ describe("scroll-handler.ts", () => { // Should trigger play since scrollY (150) >= position.in (100) expect(play).toHaveBeenCalledWith(mockMosElement1); }); + + it("should preserve animated element states during resize (flicker fix)", () => { + // Setup: Create elements with different animation states + const animatedElement = { + ...mockMosElement1, + animated: true, // Already animated + }; + const nonAnimatedElement = { + ...mockMosElement2, + animated: false, // Not yet animated + }; + + vi.mocked(getPreparedElements).mockReturnValue([animatedElement, nonAnimatedElement]); + vi.mocked(isElementAboveViewport).mockReturnValue(false); + + evaluateElementPositions(); + + // Should recalculate positions for both elements + expect(getPositionIn).toHaveBeenCalledWith(animatedElement.element, animatedElement.options); + expect(getPositionIn).toHaveBeenCalledWith( + nonAnimatedElement.element, + nonAnimatedElement.options, + ); + + // Should NOT reset initial state for already-animated element (prevents flicker) + expect(setInitialState).not.toHaveBeenCalledWith(animatedElement); + expect(setFinalState).not.toHaveBeenCalledWith(animatedElement); + + // Should still set initial state for non-animated element + expect(setInitialState).toHaveBeenCalledWith(nonAnimatedElement); + }); }); describe("scroll event processing", () => { diff --git a/packages/motion-on-scroll/src/helpers/scroll-handler.ts b/packages/motion-on-scroll/src/helpers/scroll-handler.ts index 4d93671..fde0e8d 100644 --- a/packages/motion-on-scroll/src/helpers/scroll-handler.ts +++ b/packages/motion-on-scroll/src/helpers/scroll-handler.ts @@ -131,11 +131,17 @@ function setElementInitialState(elementData: MosElement): void { /** * Refreshes all tracked elements by recalculating positions and states * Called when the library needs to update after configuration changes + * Preserves current animation states to prevent flicker during resize */ export function evaluateElementPositions(): void { getPreparedElements().forEach((elementData) => { calculateElementTriggerPositions(elementData); - setElementInitialState(elementData); + + // Only reset initial state for elements that haven't been animated yet + // This prevents flicker during resize for already-animated elements + if (!elementData.animated) { + setElementInitialState(elementData); + } }); // Process current scroll position to animate elements already in viewport diff --git a/packages/motion-on-scroll/src/index.ts b/packages/motion-on-scroll/src/index.ts index 1cf9d38..f5bf45b 100644 --- a/packages/motion-on-scroll/src/index.ts +++ b/packages/motion-on-scroll/src/index.ts @@ -42,16 +42,16 @@ let isLibraryActive = false; * Only applied on first initialization when timeUnits is set to "s" * @param config - The configuration object to potentially modify */ -function adjustTimeUnitsOnFirstInit(config: MosOptions): void { +function adjustTimeUnitsOnFirstInit(config: MosOptions, newOptions: PartialMosOptions): void { if (isLibraryActive || config.timeUnits !== "s") return; // Convert default duration from ms to seconds if not explicitly set - if (config.duration == null) { + if (newOptions.duration == null) { config.duration = DEFAULT_OPTIONS.duration / 1000; } // Convert default delay from ms to seconds if not explicitly set - if (config.delay == null) { + if (newOptions.delay == null) { config.delay = DEFAULT_OPTIONS.delay / 1000; } } @@ -116,7 +116,7 @@ function init(options: PartialMosOptions = {}): HTMLElement[] { libraryConfig = { ...DEFAULT_OPTIONS, ...options }; // Handle time unit conversion on first initialization - adjustTimeUnitsOnFirstInit(libraryConfig); + adjustTimeUnitsOnFirstInit(libraryConfig, options); // If already initialized, just refresh with new options if (isLibraryActive) {