Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 7 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
"plugins": ["@typescript-eslint", "no-only-tests", "eslint-comments"],
"ignorePatterns": ["node_modules", "dist"],
"parserOptions": {
"project": ["./tsconfig.json", "./tsconfig.node.json", "./test/tsconfig.json"],
"project": [
"./tsconfig.json",
"./tsconfig.node.json",
"./test/tsconfig.json",
"./e2e/tsconfig.json",
"./playground/tsconfig.json"
],
"tsconfigRootDir": ".",
"sourceType": "module"
},
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm

- name: Install dependencies
Expand Down
36 changes: 35 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ jobs:

- uses: pnpm/action-setup@v4

# Node 22, not 20: jsdom 30 pulls in undici, which reads
# `markAsUncloneable` off node:worker_threads unconditionally. That only
# exists from Node 22.10, so on Node 20 every jsdom test worker dies at
# startup with "[vitest-pool]: Failed to start forks worker".
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm

- name: Install dependencies
Expand All @@ -34,3 +38,33 @@ jobs:

- name: Lint
run: pnpm lint

e2e:
name: End-to-end
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --no-frozen-lockfile --ignore-scripts

- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium firefox webkit

- name: Run Playwright tests
run: pnpm run test:e2e

- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 7
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

dist
node_modules
storybook-static
test-results
playwright-report
coverage
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
dist
node_modules
pnpm-lock.yaml
test-results
playwright-report
coverage
6 changes: 0 additions & 6 deletions .storybook/main.ts

This file was deleted.

7 changes: 0 additions & 7 deletions .storybook/preview.tsx

This file was deleted.

17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,6 @@ For cases where you don't want to render a `<Motion>` component — e.g. animati

```tsx
import {motion} from "solid-motion"

;<div
ref={motion(() => ({
initial: {opacity: 0},
Expand Down Expand Up @@ -391,9 +390,21 @@ The following types are exported for typing your own components and helpers:

## Examples

Every feature documented above has a live, interactive example in this repo's Storybook. Run it locally with:
Every feature documented above has a live example in this repo's playground. Run it locally with:

```bash
pnpm install
pnpm run storybook
pnpm run dev
```

That serves an index of every demo; each one is also reachable directly at `?demo=<id>`. The playground doubles as the app the Playwright suite drives, so the demos are kept working by the tests rather than by hand.

## Testing

```bash
pnpm test # Vitest: the state machine, in jsdom and in SSR
pnpm run test:coverage
pnpm run test:e2e # Playwright: real browsers, across Chromium, Firefox and WebKit
```

Vitest covers the engine's own logic. Playwright covers everything jsdom cannot reach: real animation interpolation through the Web Animations API, real `IntersectionObserver` for `inView`, real pointer input for `hover`/`press`, and real scrolling for `useScroll`.
126 changes: 126 additions & 0 deletions e2e/animate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {expect, test} from "@playwright/test"
import {computed, opacity, openDemo, settled, translateX} from "./helpers.js"

test.describe("animate", () => {
test("animates from initial to the animate target", async ({page}) => {
await openDemo(page, "basic-enter")

const box = page.getByTestId("box")
// starts at the initial target rather than jumping to animate
expect(await opacity(box)).toBeLessThan(1)
expect(Number(await settled(box))).toBeCloseTo(1, 1)

Check failure on line 11 in e2e/animate.spec.ts

View workflow job for this annotation

GitHub Actions / End-to-end

[webkit] › e2e/animate.spec.ts:5:2 › animate › animates from initial to the animate target

1) [webkit] › e2e/animate.spec.ts:5:2 › animate › animates from initial to the animate target ──── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(received).toBeCloseTo(expected, precision) Expected: 1 Received: 0.021363 Expected precision: 1 Expected difference: < 0.05 Received difference: 0.978637 9 | // starts at the initial target rather than jumping to animate 10 | expect(await opacity(box)).toBeLessThan(1) > 11 | expect(Number(await settled(box))).toBeCloseTo(1, 1) | ^ 12 | }) 13 | 14 | test("initial={false} applies the animate target with no transition", async ({page}) => { at /home/runner/work/solid-motion/solid-motion/e2e/animate.spec.ts:11:38

Check failure on line 11 in e2e/animate.spec.ts

View workflow job for this annotation

GitHub Actions / End-to-end

[webkit] › e2e/animate.spec.ts:5:2 › animate › animates from initial to the animate target

1) [webkit] › e2e/animate.spec.ts:5:2 › animate › animates from initial to the animate target ──── Error: expect(received).toBeCloseTo(expected, precision) Expected: 1 Received: 0 Expected precision: 1 Expected difference: < 0.05 Received difference: 1 9 | // starts at the initial target rather than jumping to animate 10 | expect(await opacity(box)).toBeLessThan(1) > 11 | expect(Number(await settled(box))).toBeCloseTo(1, 1) | ^ 12 | }) 13 | 14 | test("initial={false} applies the animate target with no transition", async ({page}) => { at /home/runner/work/solid-motion/solid-motion/e2e/animate.spec.ts:11:38
})

test("initial={false} applies the animate target with no transition", async ({page}) => {
await openDemo(page, "initial-false")

// the demo's transition is 5s, so anything but an instant apply is visible
const box = page.getByTestId("box")
expect(await opacity(box)).toBeCloseTo(0.4, 1)
expect(await computed(box, "transform")).toContain("100")
})

test("no animation runs when initial already equals animate", async ({page}) => {
await openDemo(page, "no-op-when-equal")

await page.waitForTimeout(300)
await expect(page.getByTestId("starts")).toHaveText("0")
expect(await opacity(page.getByTestId("box"))).toBeCloseTo(0.6, 1)
})

test("a reactive animate target re-triggers the animation", async ({page}) => {
await openDemo(page, "reactive-animate")

const box = page.getByTestId("box")
expect(Number(await settled(box))).toBeCloseTo(0.25, 1)

await page.getByTestId("toggle").click()
expect(Number(await settled(box))).toBeCloseTo(1, 1)

await page.getByTestId("toggle").click()
expect(Number(await settled(box))).toBeCloseTo(0.25, 1)
})
})

test.describe("transitions", () => {
test("Motion One's `easing` spelling is accepted", async ({page}) => {
await openDemo(page, "legacy-easing")

// an unrecognised easing would throw or snap; this should interpolate
const box = page.getByTestId("box")
expect(Number(await settled(box))).toBeCloseTo(1, 1)
})

/*
The per-value override sets only `duration`, so it has to inherit the rest
of the base transition. `x` runs 1.5s against the base 0.2s, so opacity
must finish well before x does.
*/
test("a per-value override inherits the base transition", async ({page}) => {
await openDemo(page, "per-value-override")

const box = page.getByTestId("box")
await expect.poll(() => opacity(box)).toBeGreaterThan(0.95)

const x = await computed(box, "transform")
expect(x).not.toContain("120")

await expect.poll(() => computed(box, "transform"), {timeout: 4000}).toContain("120")
})

test("a target's own transition overrides the component transition", async ({page}) => {
await openDemo(page, "per-target-override")

// base duration is 0.05s, the target's own is 1.5s: still mid-flight here
await page.waitForTimeout(300)
expect(await opacity(page.getByTestId("box"))).toBeLessThan(0.95)
expect(Number(await settled(page.getByTestId("box")))).toBeCloseTo(1, 1)
})

test("keyframe arrays step through every value", async ({page}) => {
await openDemo(page, "keyframes")

const box = page.getByTestId("box")
const seen = new Set<string>()
for (let i = 0; i < 25; i++) {
seen.add(await computed(box, "transform"))
await page.waitForTimeout(20)
}
// a single jump to the end would give one or two distinct values
expect(seen.size).toBeGreaterThan(3)
await expect.poll(() => computed(box, "transform")).toContain("40")
})
})

test.describe("variants", () => {
test("string keys resolve against the variants map", async ({page}) => {
await openDemo(page, "variants")

expect(Number(await settled(page.getByTestId("box")))).toBeCloseTo(1, 1)
})

test("a descendant inherits the ancestor's initial variant key", async ({page}) => {
await openDemo(page, "variant-inheritance")

/*
The child sets no `initial` of its own, so it inherits the key "hidden"
from its parent and resolves it against its own variants map, starting
translated by 60px rather than at rest.
*/
const child = page.getByTestId("child")
expect(await translateX(child)).toBeGreaterThan(40)

await settled(child, "transform")
expect(await translateX(child)).toBeCloseTo(0, 1)
})

test("swapping the variants map re-animates an unchanged key", async ({page}) => {
await openDemo(page, "reactive-variants")

const box = page.getByTestId("box")
expect(Number(await settled(box))).toBeCloseTo(0.3, 1)

await page.getByTestId("swap").click()
expect(Number(await settled(box))).toBeCloseTo(0.9, 1)
})
})
Loading
Loading