@vysmo/scroll 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/observer.d.ts +36 -0
- package/dist/observer.d.ts.map +1 -0
- package/dist/observer.js +82 -0
- package/dist/observer.js.map +1 -0
- package/dist/progress.d.ts +14 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +36 -0
- package/dist/progress.js.map +1 -0
- package/dist/scroll-effect.d.ts +37 -0
- package/dist/scroll-effect.d.ts.map +1 -0
- package/dist/scroll-effect.js +39 -0
- package/dist/scroll-effect.js.map +1 -0
- package/dist/scroll-transition.d.ts +38 -0
- package/dist/scroll-transition.d.ts.map +1 -0
- package/dist/scroll-transition.js +42 -0
- package/dist/scroll-transition.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/zones.d.ts +56 -0
- package/dist/zones.d.ts.map +1 -0
- package/dist/zones.js +94 -0
- package/dist/zones.js.map +1 -0
- package/package.json +52 -0
- package/src/__tests__/__screenshots__/progress.test.ts/createScrollProgress-reaches-1-after-fully-scrolling-past-the-element-1.png +0 -0
- package/src/__tests__/observer.test.ts +60 -0
- package/src/__tests__/progress.test.ts +126 -0
- package/src/__tests__/scroll-effect.test.ts +135 -0
- package/src/__tests__/scroll-transition.test.ts +167 -0
- package/src/__tests__/ssr.test.ts +37 -0
- package/src/__tests__/types-check.ts +86 -0
- package/src/__tests__/zones.test.ts +175 -0
- package/src/index.ts +9 -0
- package/src/observer.ts +89 -0
- package/src/progress.ts +39 -0
- package/src/scroll-effect.ts +72 -0
- package/src/scroll-transition.ts +80 -0
- package/src/types.ts +19 -0
- package/src/zones.ts +103 -0
package/dist/zones.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoothstep — `t² · (3 − 2t)`. C1-continuous S-curve that matches
|
|
3
|
+
* linear at 0, 0.5, and 1 but eases in and out near the boundaries.
|
|
4
|
+
* Used as the default ramp shape for every zone helper so transitions
|
|
5
|
+
* and effects decelerate smoothly into their plateaus instead of
|
|
6
|
+
* snapping on the derivative discontinuity of a pure linear ramp.
|
|
7
|
+
*
|
|
8
|
+
* Exported so callers can reach for it explicitly, or compose it.
|
|
9
|
+
*/
|
|
10
|
+
export const smoothstep = (t) => t * t * (3 - 2 * t);
|
|
11
|
+
/**
|
|
12
|
+
* Clamp progress to a sub-range. Outside the range, the output stays flat
|
|
13
|
+
* (0 before `start`, 1 after `end`). Inside, progress is remapped from
|
|
14
|
+
* `[start, end]` to `[0, 1]` through the supplied ease (default:
|
|
15
|
+
* {@link smoothstep}).
|
|
16
|
+
*
|
|
17
|
+
* scrollRange(0.1, 0.5) — transition plays between 10% and 50% of the
|
|
18
|
+
* full viewport sweep; does nothing before, stays at its final state
|
|
19
|
+
* after. Pass `ease: (t) => t` for a linear clamp.
|
|
20
|
+
*
|
|
21
|
+
* Designed for `createScrollTransition` so the transition completes at a
|
|
22
|
+
* point the author chooses — typically well before the section exits the
|
|
23
|
+
* viewport — and then holds its final state while the user keeps scrolling.
|
|
24
|
+
*/
|
|
25
|
+
export function scrollRange(start, end, ease = smoothstep) {
|
|
26
|
+
if (end <= start)
|
|
27
|
+
return (p) => (p < start ? 0 : 1);
|
|
28
|
+
const span = end - start;
|
|
29
|
+
return (p) => {
|
|
30
|
+
if (p <= start)
|
|
31
|
+
return 0;
|
|
32
|
+
if (p >= end)
|
|
33
|
+
return 1;
|
|
34
|
+
return ease((p - start) / span);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Three-zone bathtub envelope for effects.
|
|
39
|
+
*
|
|
40
|
+
* scrollZones(0.25, 0.85) — effect ramps from max at `p = 0` down to
|
|
41
|
+
* zero at `p = 0.25` (smoothly by default), stays at zero through the
|
|
42
|
+
* clear zone (`0.25 ≤ p ≤ 0.85`), then ramps back up to max by
|
|
43
|
+
* `p = 1`.
|
|
44
|
+
*
|
|
45
|
+
* Designed for `createScrollEffect` where "identity" is zero intensity —
|
|
46
|
+
* so the image reads cleanly while the section is visible and the effect
|
|
47
|
+
* only appears as it enters and exits the viewport. Pass `ease: (t) => t`
|
|
48
|
+
* for a linear ramp; default is {@link smoothstep}.
|
|
49
|
+
*/
|
|
50
|
+
export function scrollZones(clearStart, clearEnd, ease = smoothstep) {
|
|
51
|
+
return (p) => {
|
|
52
|
+
if (p < clearStart) {
|
|
53
|
+
if (clearStart <= 0)
|
|
54
|
+
return 0;
|
|
55
|
+
return 1 - ease(p / clearStart);
|
|
56
|
+
}
|
|
57
|
+
if (p > clearEnd) {
|
|
58
|
+
if (clearEnd >= 1)
|
|
59
|
+
return 0;
|
|
60
|
+
return ease((p - clearEnd) / (1 - clearEnd));
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Three-zone plateau envelope — the inverse of {@link scrollZones}.
|
|
67
|
+
*
|
|
68
|
+
* scrollPlateau(0.3, 0.7) — rises from 0 at `p = 0` up to 1 at
|
|
69
|
+
* `p = 0.3` (smoothly by default), holds at 1 across the clear zone
|
|
70
|
+
* (`0.3 ≤ p ≤ 0.7`), then falls back to 0 by `p = 1`.
|
|
71
|
+
*
|
|
72
|
+
* Designed for `createScrollTransition` where "identity" is the fully
|
|
73
|
+
* transitioned state (progress 1). The transition plays in as the
|
|
74
|
+
* section enters, holds its final frame across the clear zone, then
|
|
75
|
+
* plays back out (reverse) as the section exits. Smoothstep default
|
|
76
|
+
* prevents the harsh snap that a linear ramp produces at the plateau
|
|
77
|
+
* boundaries.
|
|
78
|
+
*/
|
|
79
|
+
export function scrollPlateau(clearStart, clearEnd, ease = smoothstep) {
|
|
80
|
+
return (p) => {
|
|
81
|
+
if (p < clearStart) {
|
|
82
|
+
if (clearStart <= 0)
|
|
83
|
+
return 1;
|
|
84
|
+
return ease(p / clearStart);
|
|
85
|
+
}
|
|
86
|
+
if (p > clearEnd) {
|
|
87
|
+
if (clearEnd >= 1)
|
|
88
|
+
return 1;
|
|
89
|
+
return 1 - ease((p - clearEnd) / (1 - clearEnd));
|
|
90
|
+
}
|
|
91
|
+
return 1;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=zones.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zones.js","sourceRoot":"","sources":["../src/zones.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,UAAU,GAAW,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AAE7D;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,WAAW,CACzB,KAAa,EACb,GAAW,EACX,OAAe,UAAU;IAEzB,IAAI,GAAG,IAAI,KAAK;QAAE,OAAO,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,CAAC;IACzB,OAAO,CAAC,CAAC,EAAE,EAAE;QACX,IAAI,CAAC,IAAI,KAAK;YAAE,OAAO,CAAC,CAAC;QACzB,IAAI,CAAC,IAAI,GAAG;YAAE,OAAO,CAAC,CAAC;QACvB,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC;IAClC,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CACzB,UAAkB,EAClB,QAAgB,EAChB,OAAe,UAAU;IAEzB,OAAO,CAAC,CAAC,EAAE,EAAE;QACX,IAAI,CAAC,GAAG,UAAU,EAAE,CAAC;YACnB,IAAI,UAAU,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAC9B,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC;QAClC,CAAC;QACD,IAAI,CAAC,GAAG,QAAQ,EAAE,CAAC;YACjB,IAAI,QAAQ,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAC5B,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC;QAC/C,CAAC;QACD,OAAO,CAAC,CAAC;IACX,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,aAAa,CAC3B,UAAkB,EAClB,QAAgB,EAChB,OAAe,UAAU;IAEzB,OAAO,CAAC,CAAC,EAAE,EAAE;QACX,IAAI,CAAC,GAAG,UAAU,EAAE,CAAC;YACnB,IAAI,UAAU,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAC9B,OAAO,IAAI,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC;QAC9B,CAAC;QACD,IAAI,CAAC,GAAG,QAAQ,EAAE,CAAC;YACjB,IAAI,QAAQ,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAC5B,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC;QACnD,CAAC;QACD,OAAO,CAAC,CAAC;IACX,CAAC,CAAC;AACJ,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vysmo/scroll",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scroll-driven primitives that compose with the ecosystem: bind scroll progress to any @vysmo/transitions or @vysmo/effects render. Shared rAF-throttled observer, headless.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"scroll",
|
|
8
|
+
"scroll-driven",
|
|
9
|
+
"progress",
|
|
10
|
+
"scroll-transition",
|
|
11
|
+
"scroll-effect",
|
|
12
|
+
"webgl",
|
|
13
|
+
"headless"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"module": "./src/index.ts",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@vysmo/effects": "0.1.0",
|
|
34
|
+
"@vysmo/transitions": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@vitest/browser": "^3.2.4",
|
|
38
|
+
"esbuild": "^0.28.0",
|
|
39
|
+
"playwright": "^1.59.1",
|
|
40
|
+
"typescript": "^5.6.3",
|
|
41
|
+
"vitest": "^3.2.4"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
46
|
+
"test": "vitest run && vitest run --config vitest.ssr.config.ts",
|
|
47
|
+
"test:browser": "vitest run",
|
|
48
|
+
"test:ssr": "vitest run --config vitest.ssr.config.ts",
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"size": "node scripts/check-bundle-size.mjs"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ScrollObserver, sharedScrollObserver } from "../index.js";
|
|
3
|
+
|
|
4
|
+
let el: HTMLElement;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
el = document.createElement("div");
|
|
8
|
+
el.style.height = "200px";
|
|
9
|
+
el.style.width = "200px";
|
|
10
|
+
document.body.appendChild(el);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
el.remove();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("ScrollObserver", () => {
|
|
18
|
+
it("delivers the current rect + viewport on flush", () => {
|
|
19
|
+
const observer = new ScrollObserver();
|
|
20
|
+
const onScroll = vi.fn();
|
|
21
|
+
observer.subscribe(el, { onScroll });
|
|
22
|
+
observer.flush();
|
|
23
|
+
expect(onScroll).toHaveBeenCalledTimes(1);
|
|
24
|
+
const [rect, viewport] = onScroll.mock.calls[0]!;
|
|
25
|
+
expect(rect).toBeInstanceOf(DOMRect);
|
|
26
|
+
expect(viewport.width).toBe(window.innerWidth);
|
|
27
|
+
expect(viewport.height).toBe(window.innerHeight);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("notifies multiple subscribers on one flush", () => {
|
|
31
|
+
const observer = new ScrollObserver();
|
|
32
|
+
const el2 = document.createElement("div");
|
|
33
|
+
document.body.appendChild(el2);
|
|
34
|
+
const a = vi.fn();
|
|
35
|
+
const b = vi.fn();
|
|
36
|
+
observer.subscribe(el, { onScroll: a });
|
|
37
|
+
observer.subscribe(el2, { onScroll: b });
|
|
38
|
+
observer.flush();
|
|
39
|
+
expect(a).toHaveBeenCalledTimes(1);
|
|
40
|
+
expect(b).toHaveBeenCalledTimes(1);
|
|
41
|
+
el2.remove();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("unsubscribing stops further notifications for that element", () => {
|
|
45
|
+
const observer = new ScrollObserver();
|
|
46
|
+
const onScroll = vi.fn();
|
|
47
|
+
const unsub = observer.subscribe(el, { onScroll });
|
|
48
|
+
observer.flush();
|
|
49
|
+
expect(onScroll).toHaveBeenCalledTimes(1);
|
|
50
|
+
unsub();
|
|
51
|
+
observer.flush();
|
|
52
|
+
expect(onScroll).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("sharedScrollObserver returns a stable singleton", () => {
|
|
56
|
+
const a = sharedScrollObserver();
|
|
57
|
+
const b = sharedScrollObserver();
|
|
58
|
+
expect(a).toBe(b);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ScrollObserver } from "../observer.js";
|
|
3
|
+
import { createScrollProgress } from "../progress.js";
|
|
4
|
+
|
|
5
|
+
let el: HTMLElement;
|
|
6
|
+
let spacerBefore: HTMLElement;
|
|
7
|
+
let spacerAfter: HTMLElement;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
spacerBefore = document.createElement("div");
|
|
11
|
+
spacerBefore.style.cssText = "height:2000px;";
|
|
12
|
+
document.body.appendChild(spacerBefore);
|
|
13
|
+
|
|
14
|
+
el = document.createElement("div");
|
|
15
|
+
el.style.cssText = "width:100%;height:400px;background:red;";
|
|
16
|
+
document.body.appendChild(el);
|
|
17
|
+
|
|
18
|
+
// Trailing spacer ensures the document is tall enough for scrollTo()
|
|
19
|
+
// to actually reach large Y values without hitting the scroll maximum.
|
|
20
|
+
spacerAfter = document.createElement("div");
|
|
21
|
+
spacerAfter.style.cssText = "height:5000px;";
|
|
22
|
+
document.body.appendChild(spacerAfter);
|
|
23
|
+
|
|
24
|
+
window.scrollTo(0, 0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
spacerBefore.remove();
|
|
29
|
+
el.remove();
|
|
30
|
+
spacerAfter.remove();
|
|
31
|
+
window.scrollTo(0, 0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("createScrollProgress", () => {
|
|
35
|
+
it("starts at 0 when the element is below the viewport", async () => {
|
|
36
|
+
const onProgress = vi.fn();
|
|
37
|
+
const h = createScrollProgress({ element: el, onProgress });
|
|
38
|
+
await waitForFrame();
|
|
39
|
+
expect(onProgress).toHaveBeenCalled();
|
|
40
|
+
expect(onProgress.mock.calls.at(-1)![0]).toBe(0);
|
|
41
|
+
h.destroy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("advances as the user scrolls through the element", async () => {
|
|
45
|
+
const values: number[] = [];
|
|
46
|
+
const h = createScrollProgress({
|
|
47
|
+
element: el,
|
|
48
|
+
onProgress: (p) => values.push(p),
|
|
49
|
+
});
|
|
50
|
+
await waitForFrame();
|
|
51
|
+
window.scrollTo(0, 1500);
|
|
52
|
+
await waitForFrame();
|
|
53
|
+
window.scrollTo(0, 2400);
|
|
54
|
+
await waitForFrame();
|
|
55
|
+
expect(values[0]).toBe(0);
|
|
56
|
+
expect(values.at(-1)!).toBeGreaterThan(values[0]!);
|
|
57
|
+
h.destroy();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("reaches 1 after fully scrolling past the element", async () => {
|
|
61
|
+
const values: number[] = [];
|
|
62
|
+
const h = createScrollProgress({
|
|
63
|
+
element: el,
|
|
64
|
+
onProgress: (p) => values.push(p),
|
|
65
|
+
});
|
|
66
|
+
await waitForFrame();
|
|
67
|
+
window.scrollTo(0, 5000);
|
|
68
|
+
await waitForFrame();
|
|
69
|
+
expect(values.at(-1)!).toBe(1);
|
|
70
|
+
h.destroy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("clamps progress into [0, 1]", async () => {
|
|
74
|
+
const values: number[] = [];
|
|
75
|
+
const h = createScrollProgress({
|
|
76
|
+
element: el,
|
|
77
|
+
onProgress: (p) => values.push(p),
|
|
78
|
+
});
|
|
79
|
+
await waitForFrame();
|
|
80
|
+
for (const y of [0, 100, 1500, 3000, 10000]) {
|
|
81
|
+
window.scrollTo(0, y);
|
|
82
|
+
await waitForFrame();
|
|
83
|
+
}
|
|
84
|
+
for (const p of values) {
|
|
85
|
+
expect(p).toBeGreaterThanOrEqual(0);
|
|
86
|
+
expect(p).toBeLessThanOrEqual(1);
|
|
87
|
+
}
|
|
88
|
+
h.destroy();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("applies the ease() callback to remap the raw value", () => {
|
|
92
|
+
const observer = new ScrollObserver();
|
|
93
|
+
const onProgress = vi.fn();
|
|
94
|
+
observer.subscribe(el, {
|
|
95
|
+
onScroll(rect, viewport) {
|
|
96
|
+
const raw = (viewport.height - rect.top) / (viewport.height + rect.height);
|
|
97
|
+
const clamped = Math.max(0, Math.min(1, raw));
|
|
98
|
+
// Mirror the internal calculation: ease(clamped) = clamped * 2
|
|
99
|
+
const mapped = clamped * 2;
|
|
100
|
+
onProgress(mapped);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
observer.flush();
|
|
104
|
+
expect(onProgress).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does not re-emit when the clamped value is unchanged", async () => {
|
|
108
|
+
const onProgress = vi.fn();
|
|
109
|
+
const h = createScrollProgress({ element: el, onProgress });
|
|
110
|
+
await waitForFrame();
|
|
111
|
+
// Two identical rAF ticks at the same scroll position should collapse.
|
|
112
|
+
const before = onProgress.mock.calls.length;
|
|
113
|
+
await waitForFrame();
|
|
114
|
+
await waitForFrame();
|
|
115
|
+
const after = onProgress.mock.calls.length;
|
|
116
|
+
expect(after).toBe(before);
|
|
117
|
+
h.destroy();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
async function waitForFrame(): Promise<void> {
|
|
122
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
123
|
+
// A second rAF gives the observer time to run its flush after the
|
|
124
|
+
// scroll event has been dispatched synchronously.
|
|
125
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
126
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Runner as EffectRunner, Effect } from "@vysmo/effects";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { createScrollEffect } from "../scroll-effect.js";
|
|
4
|
+
|
|
5
|
+
function mockRunner(): EffectRunner & { calls: Array<{ effect: unknown; args: unknown }> } {
|
|
6
|
+
const calls: Array<{ effect: unknown; args: unknown }> = [];
|
|
7
|
+
const runner = {
|
|
8
|
+
calls,
|
|
9
|
+
render(effect: unknown, args: unknown) {
|
|
10
|
+
calls.push({ effect, args });
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
return runner as unknown as EffectRunner & typeof runner;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FAKE_EFFECT = {
|
|
17
|
+
name: "fake-blur",
|
|
18
|
+
defaults: { radius: 0 },
|
|
19
|
+
shader: { glsl: "" },
|
|
20
|
+
} as unknown as Effect<{ radius: number }>;
|
|
21
|
+
|
|
22
|
+
let spacerBefore: HTMLElement;
|
|
23
|
+
let section: HTMLElement;
|
|
24
|
+
let spacerAfter: HTMLElement;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
spacerBefore = document.createElement("div");
|
|
28
|
+
spacerBefore.style.cssText = "height:2000px;";
|
|
29
|
+
document.body.appendChild(spacerBefore);
|
|
30
|
+
|
|
31
|
+
section = document.createElement("div");
|
|
32
|
+
section.style.cssText = "width:100%;height:400px;";
|
|
33
|
+
document.body.appendChild(section);
|
|
34
|
+
|
|
35
|
+
spacerAfter = document.createElement("div");
|
|
36
|
+
spacerAfter.style.cssText = "height:5000px;";
|
|
37
|
+
document.body.appendChild(spacerAfter);
|
|
38
|
+
|
|
39
|
+
window.scrollTo(0, 0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
spacerBefore.remove();
|
|
44
|
+
section.remove();
|
|
45
|
+
spacerAfter.remove();
|
|
46
|
+
window.scrollTo(0, 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function waitForFrame(): Promise<void> {
|
|
50
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
51
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("createScrollEffect", () => {
|
|
55
|
+
it("invokes paramsAt with progress 0 when the section is below the viewport", async () => {
|
|
56
|
+
const runner = mockRunner();
|
|
57
|
+
const paramsSeen: number[] = [];
|
|
58
|
+
const h = createScrollEffect({
|
|
59
|
+
section,
|
|
60
|
+
runner,
|
|
61
|
+
effect: FAKE_EFFECT,
|
|
62
|
+
source: {} as unknown as HTMLImageElement,
|
|
63
|
+
paramsAt: (p) => {
|
|
64
|
+
paramsSeen.push(p);
|
|
65
|
+
return { radius: p * 10 };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
await waitForFrame();
|
|
69
|
+
expect(paramsSeen[0]).toBe(0);
|
|
70
|
+
const last = runner.calls.at(-1)!;
|
|
71
|
+
expect((last.args as { params: { radius: number } }).params).toEqual({ radius: 0 });
|
|
72
|
+
h.destroy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("maps progress through paramsAt as the user scrolls", async () => {
|
|
76
|
+
const runner = mockRunner();
|
|
77
|
+
const h = createScrollEffect({
|
|
78
|
+
section,
|
|
79
|
+
runner,
|
|
80
|
+
effect: FAKE_EFFECT,
|
|
81
|
+
source: {} as unknown as HTMLImageElement,
|
|
82
|
+
paramsAt: (p) => ({ radius: p * 20 }),
|
|
83
|
+
});
|
|
84
|
+
await waitForFrame();
|
|
85
|
+
window.scrollTo(0, 5000);
|
|
86
|
+
await waitForFrame();
|
|
87
|
+
const lastRadius = (runner.calls.at(-1)!.args as { params: { radius: number } }).params
|
|
88
|
+
.radius;
|
|
89
|
+
expect(lastRadius).toBe(20);
|
|
90
|
+
h.destroy();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("respects ease() before calling paramsAt", async () => {
|
|
94
|
+
const runner = mockRunner();
|
|
95
|
+
const easeCalls: number[] = [];
|
|
96
|
+
const paramsAtInputs: number[] = [];
|
|
97
|
+
const h = createScrollEffect({
|
|
98
|
+
section,
|
|
99
|
+
runner,
|
|
100
|
+
effect: FAKE_EFFECT,
|
|
101
|
+
source: {} as unknown as HTMLImageElement,
|
|
102
|
+
ease: (t) => {
|
|
103
|
+
easeCalls.push(t);
|
|
104
|
+
return t * t;
|
|
105
|
+
},
|
|
106
|
+
paramsAt: (p) => {
|
|
107
|
+
paramsAtInputs.push(p);
|
|
108
|
+
return { radius: p };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
await waitForFrame();
|
|
112
|
+
expect(easeCalls.length).toBeGreaterThan(0);
|
|
113
|
+
expect(paramsAtInputs.length).toBeGreaterThan(0);
|
|
114
|
+
// paramsAt input is the eased value, not the raw.
|
|
115
|
+
expect(paramsAtInputs[0]).toBeCloseTo(0, 5);
|
|
116
|
+
h.destroy();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("destroy() unsubscribes — no further renders after scroll", async () => {
|
|
120
|
+
const runner = mockRunner();
|
|
121
|
+
const h = createScrollEffect({
|
|
122
|
+
section,
|
|
123
|
+
runner,
|
|
124
|
+
effect: FAKE_EFFECT,
|
|
125
|
+
source: {} as unknown as HTMLImageElement,
|
|
126
|
+
paramsAt: (p) => ({ radius: p * 10 }),
|
|
127
|
+
});
|
|
128
|
+
await waitForFrame();
|
|
129
|
+
h.destroy();
|
|
130
|
+
const before = runner.calls.length;
|
|
131
|
+
window.scrollTo(0, 5000);
|
|
132
|
+
await waitForFrame();
|
|
133
|
+
expect(runner.calls.length).toBe(before);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { Runner as TransitionRunner, Transition, UniformParams } from "@vysmo/transitions";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createScrollTransition } from "../scroll-transition.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mock Runner — satisfies the narrow shape `createScrollTransition` uses
|
|
7
|
+
* without touching WebGL. The test asserts that scroll drives render() with
|
|
8
|
+
* the correct progress / args, which is the whole contract here.
|
|
9
|
+
*/
|
|
10
|
+
function mockRunner(): TransitionRunner & { calls: Array<{ transition: unknown; args: unknown }> } {
|
|
11
|
+
const calls: Array<{ transition: unknown; args: unknown }> = [];
|
|
12
|
+
const runner = {
|
|
13
|
+
calls,
|
|
14
|
+
render(transition: unknown, args: unknown) {
|
|
15
|
+
calls.push({ transition, args });
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
return runner as unknown as TransitionRunner & typeof runner;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FAKE_TRANSITION = {
|
|
22
|
+
name: "fake",
|
|
23
|
+
defaults: { amount: 0.5 },
|
|
24
|
+
shader: { glsl: "" },
|
|
25
|
+
} as unknown as Transition<{ amount: number }>;
|
|
26
|
+
|
|
27
|
+
let spacerBefore: HTMLElement;
|
|
28
|
+
let section: HTMLElement;
|
|
29
|
+
let spacerAfter: HTMLElement;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
spacerBefore = document.createElement("div");
|
|
33
|
+
spacerBefore.style.cssText = "height:2000px;";
|
|
34
|
+
document.body.appendChild(spacerBefore);
|
|
35
|
+
|
|
36
|
+
section = document.createElement("div");
|
|
37
|
+
section.style.cssText = "width:100%;height:400px;";
|
|
38
|
+
document.body.appendChild(section);
|
|
39
|
+
|
|
40
|
+
spacerAfter = document.createElement("div");
|
|
41
|
+
spacerAfter.style.cssText = "height:5000px;";
|
|
42
|
+
document.body.appendChild(spacerAfter);
|
|
43
|
+
|
|
44
|
+
window.scrollTo(0, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
spacerBefore.remove();
|
|
49
|
+
section.remove();
|
|
50
|
+
spacerAfter.remove();
|
|
51
|
+
window.scrollTo(0, 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function waitForFrame(): Promise<void> {
|
|
55
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
56
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("createScrollTransition", () => {
|
|
60
|
+
it("renders at progress 0 when the section is below the viewport", async () => {
|
|
61
|
+
const runner = mockRunner();
|
|
62
|
+
const h = createScrollTransition({
|
|
63
|
+
section,
|
|
64
|
+
runner,
|
|
65
|
+
transition: FAKE_TRANSITION,
|
|
66
|
+
from: {} as unknown as UniformParams extends never ? never : HTMLImageElement,
|
|
67
|
+
to: {} as unknown as HTMLImageElement,
|
|
68
|
+
});
|
|
69
|
+
await waitForFrame();
|
|
70
|
+
expect(runner.calls.length).toBeGreaterThanOrEqual(1);
|
|
71
|
+
const last = runner.calls.at(-1)!;
|
|
72
|
+
expect(last.transition).toBe(FAKE_TRANSITION);
|
|
73
|
+
expect((last.args as { progress: number }).progress).toBe(0);
|
|
74
|
+
h.destroy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("drives progress from 0 → 1 as the user scrolls past", async () => {
|
|
78
|
+
const runner = mockRunner();
|
|
79
|
+
const h = createScrollTransition({
|
|
80
|
+
section,
|
|
81
|
+
runner,
|
|
82
|
+
transition: FAKE_TRANSITION,
|
|
83
|
+
from: {} as unknown as HTMLImageElement,
|
|
84
|
+
to: {} as unknown as HTMLImageElement,
|
|
85
|
+
});
|
|
86
|
+
await waitForFrame();
|
|
87
|
+
window.scrollTo(0, 5000);
|
|
88
|
+
await waitForFrame();
|
|
89
|
+
const progresses = runner.calls.map((c) => (c.args as { progress: number }).progress);
|
|
90
|
+
expect(progresses[0]).toBe(0);
|
|
91
|
+
expect(progresses.at(-1)).toBe(1);
|
|
92
|
+
h.destroy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("forwards params through to the runner", async () => {
|
|
96
|
+
const runner = mockRunner();
|
|
97
|
+
const h = createScrollTransition({
|
|
98
|
+
section,
|
|
99
|
+
runner,
|
|
100
|
+
transition: FAKE_TRANSITION,
|
|
101
|
+
from: {} as unknown as HTMLImageElement,
|
|
102
|
+
to: {} as unknown as HTMLImageElement,
|
|
103
|
+
params: { amount: 0.75 },
|
|
104
|
+
});
|
|
105
|
+
await waitForFrame();
|
|
106
|
+
const last = runner.calls.at(-1)!;
|
|
107
|
+
expect((last.args as { params: { amount: number } }).params).toEqual({ amount: 0.75 });
|
|
108
|
+
h.destroy();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("applies ease() to the raw progress", async () => {
|
|
112
|
+
const runner = mockRunner();
|
|
113
|
+
const h = createScrollTransition({
|
|
114
|
+
section,
|
|
115
|
+
runner,
|
|
116
|
+
transition: FAKE_TRANSITION,
|
|
117
|
+
from: {} as unknown as HTMLImageElement,
|
|
118
|
+
to: {} as unknown as HTMLImageElement,
|
|
119
|
+
// raw 0.5 → eased 0.25
|
|
120
|
+
ease: (t) => t * t,
|
|
121
|
+
});
|
|
122
|
+
await waitForFrame();
|
|
123
|
+
// Scroll midway.
|
|
124
|
+
window.scrollTo(0, 2400);
|
|
125
|
+
await waitForFrame();
|
|
126
|
+
const mids = runner.calls.map((c) => (c.args as { progress: number }).progress);
|
|
127
|
+
for (const p of mids) {
|
|
128
|
+
expect(p).toBeGreaterThanOrEqual(0);
|
|
129
|
+
expect(p).toBeLessThanOrEqual(1);
|
|
130
|
+
}
|
|
131
|
+
h.destroy();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("does not re-render when the clamped progress hasn't changed", async () => {
|
|
135
|
+
const runner = mockRunner();
|
|
136
|
+
const h = createScrollTransition({
|
|
137
|
+
section,
|
|
138
|
+
runner,
|
|
139
|
+
transition: FAKE_TRANSITION,
|
|
140
|
+
from: {} as unknown as HTMLImageElement,
|
|
141
|
+
to: {} as unknown as HTMLImageElement,
|
|
142
|
+
});
|
|
143
|
+
await waitForFrame();
|
|
144
|
+
const before = runner.calls.length;
|
|
145
|
+
await waitForFrame();
|
|
146
|
+
await waitForFrame();
|
|
147
|
+
expect(runner.calls.length).toBe(before);
|
|
148
|
+
h.destroy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("destroy() unsubscribes — no further renders after scroll", async () => {
|
|
152
|
+
const runner = mockRunner();
|
|
153
|
+
const h = createScrollTransition({
|
|
154
|
+
section,
|
|
155
|
+
runner,
|
|
156
|
+
transition: FAKE_TRANSITION,
|
|
157
|
+
from: {} as unknown as HTMLImageElement,
|
|
158
|
+
to: {} as unknown as HTMLImageElement,
|
|
159
|
+
});
|
|
160
|
+
await waitForFrame();
|
|
161
|
+
h.destroy();
|
|
162
|
+
const before = runner.calls.length;
|
|
163
|
+
window.scrollTo(0, 5000);
|
|
164
|
+
await waitForFrame();
|
|
165
|
+
expect(runner.calls.length).toBe(before);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
describe("SSR safety", () => {
|
|
4
|
+
it("window is undefined in this runtime", () => {
|
|
5
|
+
expect(typeof window).toBe("undefined");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it("the narrowed factory set imports without touching DOM", async () => {
|
|
9
|
+
const mod = await import("../index.js");
|
|
10
|
+
expect(typeof mod.createScrollProgress).toBe("function");
|
|
11
|
+
expect(typeof mod.createScrollTransition).toBe("function");
|
|
12
|
+
expect(typeof mod.createScrollEffect).toBe("function");
|
|
13
|
+
expect(typeof mod.sharedScrollObserver).toBe("function");
|
|
14
|
+
expect(typeof mod.scrollRange).toBe("function");
|
|
15
|
+
expect(typeof mod.scrollZones).toBe("function");
|
|
16
|
+
expect(typeof mod.scrollPlateau).toBe("function");
|
|
17
|
+
expect(typeof mod.smoothstep).toBe("function");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("scroll zone helpers run purely in Node", async () => {
|
|
21
|
+
const { scrollRange, scrollZones, scrollPlateau, smoothstep } =
|
|
22
|
+
await import("../index.js");
|
|
23
|
+
expect(scrollRange(0.1, 0.5)(0.3)).toBeCloseTo(0.5, 5);
|
|
24
|
+
expect(scrollZones(0.25, 0.85)(0.5)).toBe(0);
|
|
25
|
+
expect(scrollPlateau(0.3, 0.7)(0.5)).toBe(1);
|
|
26
|
+
expect(smoothstep(0.5)).toBeCloseTo(0.5, 5);
|
|
27
|
+
expect(smoothstep(0.25)).toBeCloseTo(0.15625, 5);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("sharedScrollObserver can be constructed without window access", async () => {
|
|
31
|
+
const { sharedScrollObserver } = await import("../index.js");
|
|
32
|
+
const obs = sharedScrollObserver();
|
|
33
|
+
expect(obs).toBeDefined();
|
|
34
|
+
// flush() with no window is a no-op rather than a throw.
|
|
35
|
+
expect(() => obs.flush()).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|