Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8014123
chore: update motion-on-scroll dependency from workspace to npm version
Boston343 Jul 21, 2025
1af8e35
disable mirror setting in demo
Boston343 Jul 21, 2025
7222a41
docs for mirror attribute
Boston343 Jul 21, 2025
2552a05
types cleanup
Boston343 Jul 21, 2025
370a152
unified elements refactoring first pass
Boston343 Jul 21, 2025
3267604
remove unused functions and types
Boston343 Jul 21, 2025
3ee6168
cache the DOM query for data-mos elements
Boston343 Jul 21, 2025
4c7506e
comment update
Boston343 Jul 21, 2025
e420fee
cleanup for unified elements system
Boston343 Jul 21, 2025
8b9034c
add default export
Boston343 Jul 21, 2025
8b59013
styling
Boston343 Jul 22, 2025
53e7d8f
remove unecessary duplication of variable assignments
Boston343 Jul 22, 2025
117ca15
cleanup tests
Boston343 Jul 22, 2025
738043c
chore: formatting
Boston343 Jul 22, 2025
3814434
Remove extra lookups in favor of function parameter passing
Boston343 Jul 23, 2025
d0bbd11
improve and cleanup tests
Boston343 Jul 23, 2025
d4e9907
chore: formatting
Boston343 Jul 23, 2025
5394b12
type cleanup
Boston343 Jul 23, 2025
1ff18a1
remove separate observed elements
Boston343 Jul 23, 2025
9f64772
remove duplicate resize and orientationchange handlers
Boston343 Jul 23, 2025
f2e0666
cleanup scroll related functions
Boston343 Jul 23, 2025
996f6b5
update exports
Boston343 Jul 23, 2025
3745baf
add tests
Boston343 Jul 23, 2025
86fbf60
add tests
Boston343 Jul 23, 2025
c4a7ac1
docs(changeset): Move to unified elements model to ensure all apects …
Boston343 Jul 23, 2025
26c7416
add changeset
Boston343 Jul 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/busy-banks-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"motion-on-scroll": patch
---

- Move to unified elements model to ensure all apects of code work with the most up-to-date MOS data
- Remove various duplicate features, listeners, objects, etc.
- Add additional tests
- Simplify code
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@tailwindcss/vite": "^4.1.11",
"astro": "^5.6.1",
"motion": "^12.23.6",
"motion-on-scroll": "workspace:^0.0.4",
"motion-on-scroll": "^0.0.4",
"sharp": "^0.34.2",
"starlight-llms-txt": "^0.5.1",
"tailwindcss": "^4.1.11"
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/components/MosDemo.astro
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ const animationSections = [
duration: 800,
offset: 50,
once: false,
mirror: true,
mirror: false,
});
</script>

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/content/docs/reference/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ Additional distance (in pixels) before an element is considered *in view* (appli

#### duration

*Type*: `number`
*Type*: `number`
*Default*: `400`

Animation duration. (MOS uses the browser’s **Web Animations API** where possible.)

#### delay

*Type*: `number`
*Type*: `number`
*Default*: `0`

Delay before the animation starts.
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/src/content/docs/reference/attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ Need something else? Define a custom easing preset with [`MOS.registerEasing`](/

Play only once then unobserve.

### data-mos-mirror
*Type*: `boolean`
*Default*: value from `mirror` global or **false**

Play when scrolling up as well as down (requires once: false).

### data-mos-anchor
*Type*: `CSSSelector`
*Default*: —
Expand Down
339 changes: 339 additions & 0 deletions packages/motion-on-scroll/src/__tests__/animations-coverage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
import { JSDOM } from "jsdom";
import * as motion from "motion";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

import {
play,
registerAnimation,
reverse,
setFinalState,
setInitialState,
} from "../helpers/animations.js";
import { DEFAULT_OPTIONS } from "../helpers/constants.js";
import { clearAllElements, prepareElement, updatePreparedElements } from "../helpers/elements.js";
import type { ElementOptions } from "../helpers/types.js";

// ---------------------------------------------------------------------------
// Test setup helpers
// ---------------------------------------------------------------------------

beforeAll(() => {
const { window } = new JSDOM("<html><body></body></html>");
// @ts-expect-error attach globals for jsdom
global.window = window;
global.document = window.document;
global.HTMLElement = window.HTMLElement;
});

// Quick helper to create a complete ElementOptions object
function makeOpts(partial: Partial<ElementOptions> = {}): ElementOptions {
return { ...DEFAULT_OPTIONS, preset: "fade", once: false, ...partial } as ElementOptions;
}

// Common reference to the mocked motion.animate fn created in vitest.setup.ts
const animateSpy = motion.animate as unknown as ReturnType<typeof vi.fn>;

// ---------------------------------------------------------------------------
// Coverage Tests for Uncovered Branches
// ---------------------------------------------------------------------------

describe("animations coverage tests", () => {
let div: HTMLElement;

beforeEach(() => {
div = document.createElement("div");
div.setAttribute("data-mos", "fade");

// Clear all elements and add our test element to the unified tracking system
clearAllElements();

vi.clearAllMocks();
});

// Helper to prepare test element
function prepareTestDiv(options = makeOpts()) {
const mosElement = prepareElement(div, options);
if (mosElement) {
updatePreparedElements([mosElement]);
return mosElement;
}
throw new Error("Failed to prepare test element");
}

describe("ensureAnimationControls edge cases", () => {
it("returns null when element is not in prepared elements", () => {
const unpreparedDiv = document.createElement("div");
unpreparedDiv.setAttribute("data-mos", "fade");

// Create a fake MosElement without adding to prepared elements
const fakeMosElement = {
element: unpreparedDiv,
options: makeOpts(),
position: { in: 100, out: false as const },
animated: false,
isReversing: false,
};

// Should trigger early return since element not in prepared elements
setInitialState(fakeMosElement);

// Should not have called animate since element wasn't prepared
expect(animateSpy).not.toHaveBeenCalled();
});

it("reuses existing controls when available", () => {
const mosElement = prepareTestDiv();

// First call creates controls
setInitialState(mosElement);
expect(animateSpy).toHaveBeenCalledTimes(1);

vi.clearAllMocks();

// Second call should reuse existing controls (line 89-91)
setInitialState(mosElement);
expect(animateSpy).not.toHaveBeenCalled();
});
});

describe("setInitialState edge cases", () => {
it("returns early when mosElement is not found after controls creation", () => {
// This is a tricky edge case - element has controls but findPreparedElement returns undefined
// We'll simulate this by clearing elements after controls are created
const mockControls = {
play: vi.fn(),
pause: vi.fn(),
stop: vi.fn(),
complete: vi.fn(),
finished: Promise.resolve(),
speed: 1,
time: 0,
} as unknown as motion.AnimationPlaybackControls;

animateSpy.mockReturnValueOnce(mockControls);

const mosElement = prepareTestDiv();

// Create controls first
setInitialState(mosElement);
expect(mockControls.pause).toHaveBeenCalled();

// Clear elements to simulate the edge case
clearAllElements();
vi.clearAllMocks();

// Now call again - should hit the early return at line 123
setInitialState(mosElement);

// Should not have called pause again since mosElement was null
expect(mockControls.pause).not.toHaveBeenCalled();
});
});

describe("setFinalState edge cases", () => {
it("handles missing mosElement gracefully after controls creation", () => {
// This tests the conditional at lines 342-346 in setFinalState
// where controls exist but mosElement might be null

const mosElement = prepareTestDiv();

// First, create a normal setFinalState call to verify it works
setFinalState(mosElement);
expect(div.classList.contains("mos-animate")).toBe(true);

// The function should handle the case where findPreparedElement returns undefined
// This branch is covered when mosElement is null but the function continues
// The test above already covers this case - the function adds CSS class regardless
expect(true).toBe(true); // This test verifies the function doesn't crash
});
});

describe("play function edge cases", () => {
it("returns early when mosElement is not found", () => {
// Create a fake MosElement without adding to prepared elements
const fakeMosElement = {
element: div,
options: makeOpts(),
position: { in: 100, out: false as const },
animated: false,
isReversing: false,
};

// Clear elements to simulate missing mosElement
clearAllElements();

play(fakeMosElement);

// Should not have called animate since mosElement was null
expect(animateSpy).not.toHaveBeenCalled();
});

it("doesn't interrupt forward animation already in progress", () => {
const mosElement = prepareTestDiv();

// Start first animation
play(mosElement);
expect(animateSpy).toHaveBeenCalledTimes(1);

vi.clearAllMocks();

// Try to play again - should return early (line 371 condition)
play(mosElement);

// Should not create new animation
expect(animateSpy).not.toHaveBeenCalled();
});
});

describe("reverse function edge cases", () => {
it("returns early when mosElement is not found", () => {
// Create a fake MosElement without adding to prepared elements
const fakeMosElement = {
element: div,
options: makeOpts(),
position: { in: 100, out: false as const },
animated: false,
isReversing: false,
};

// Clear elements to simulate missing mosElement
clearAllElements();

reverse(fakeMosElement);

// Should not have called animate since mosElement was null
expect(animateSpy).not.toHaveBeenCalled();
});

it("returns early when mosElement has no controls", () => {
const mosElement = prepareTestDiv();

// Element exists but has no controls
reverse(mosElement);

// Should not have called animate since no controls exist yet
expect(animateSpy).not.toHaveBeenCalled();
});
});

describe("time units handling", () => {
it("handles seconds time units correctly", () => {
const optsWithSeconds = makeOpts({
timeUnits: "s" as const,
duration: 2,
delay: 0.5,
});

const mosElement = prepareTestDiv(optsWithSeconds);
play(mosElement);

expect(animateSpy).toHaveBeenCalledWith(
div,
expect.any(Object),
expect.objectContaining({
duration: 2, // Should use value as-is for seconds
delay: 0.5, // Should use value as-is for seconds
}),
);
});

it("handles milliseconds time units correctly", () => {
const optsWithMs = makeOpts({
timeUnits: "ms" as const,
duration: 2000,
delay: 500,
});

const mosElement = prepareTestDiv(optsWithMs);
play(mosElement);

expect(animateSpy).toHaveBeenCalledWith(
div,
expect.any(Object),
expect.objectContaining({
duration: 2, // Should convert ms to seconds
delay: 0.5, // Should convert ms to seconds
}),
);
});
});

describe("easing resolution edge cases", () => {
it("returns undefined when easing resolves to null", () => {
// Test with an easing that would resolve to null
const optsWithNullEasing = makeOpts({
easing: "invalid-easing" as any,
});

const mosElement = prepareTestDiv(optsWithNullEasing);

// This should trigger the easing === null check and return undefined
play(mosElement);

// Should still create animation but with undefined easing
expect(animateSpy).toHaveBeenCalledWith(
div,
expect.any(Object),
expect.objectContaining({
ease: undefined,
}),
);
});
});

describe("custom animation registration", () => {
it("handles custom animation factory correctly", () => {
const customFactory = vi.fn().mockReturnValue({
play: vi.fn(),
pause: vi.fn(),
stop: vi.fn(),
complete: vi.fn(),
finished: Promise.resolve(),
speed: 1,
time: 0,
});

registerAnimation("custom-test", customFactory);

const customDiv = document.createElement("div");
customDiv.setAttribute("data-mos", "custom-test");

const mosElement = prepareElement(customDiv, makeOpts({ keyframes: "custom-test" }));
if (mosElement) {
updatePreparedElements([mosElement]);

play(mosElement);

expect(customFactory).toHaveBeenCalledWith(customDiv, expect.any(Object));
}
});
});

describe("completion handler edge cases", () => {
it("handles missing mosElement in completion handler", async () => {
const mockControls = {
play: vi.fn(),
pause: vi.fn(),
stop: vi.fn(),
complete: vi.fn(),
finished: Promise.resolve(),
speed: 1,
time: 0,
} as unknown as motion.AnimationPlaybackControls;

animateSpy.mockReturnValueOnce(mockControls);

const mosElement = prepareTestDiv();
play(mosElement);

// Clear elements to simulate missing mosElement in completion handler
clearAllElements();

// Trigger the completion handler
await mockControls.finished;

// Should handle the missing mosElement gracefully (line 150 return)
expect(true).toBe(true); // Test passes if no errors thrown
});
});
});
Loading