@praxisjs/motion 0.2.3 → 1.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/__tests__/decorators.test.d.ts +2 -0
  3. package/dist/__tests__/decorators.test.d.ts.map +1 -0
  4. package/dist/__tests__/decorators.test.js +179 -0
  5. package/dist/__tests__/decorators.test.js.map +1 -0
  6. package/dist/__tests__/easings.test.js +82 -55
  7. package/dist/__tests__/easings.test.js.map +1 -1
  8. package/dist/__tests__/spring.test.js +80 -34
  9. package/dist/__tests__/spring.test.js.map +1 -1
  10. package/dist/__tests__/transition.test.js +111 -43
  11. package/dist/__tests__/transition.test.js.map +1 -1
  12. package/dist/__tests__/tween.test.js +107 -94
  13. package/dist/__tests__/tween.test.js.map +1 -1
  14. package/dist/decorators.d.ts +3 -1
  15. package/dist/decorators.d.ts.map +1 -1
  16. package/dist/decorators.js +39 -16
  17. package/dist/decorators.js.map +1 -1
  18. package/dist/easings.d.ts.map +1 -1
  19. package/dist/easings.js +7 -1
  20. package/dist/easings.js.map +1 -1
  21. package/dist/index.d.ts +2 -10
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -6
  24. package/dist/index.js.map +1 -1
  25. package/dist/spring.d.ts.map +1 -1
  26. package/dist/spring.js +3 -0
  27. package/dist/spring.js.map +1 -1
  28. package/dist/transition.d.ts.map +1 -1
  29. package/dist/transition.js +34 -18
  30. package/dist/transition.js.map +1 -1
  31. package/dist/tween.d.ts.map +1 -1
  32. package/dist/tween.js +2 -1
  33. package/dist/tween.js.map +1 -1
  34. package/package.json +3 -2
  35. package/src/__tests__/decorators.test.ts +196 -0
  36. package/src/__tests__/easings.test.ts +86 -57
  37. package/src/__tests__/spring.test.ts +90 -34
  38. package/src/__tests__/transition.test.ts +122 -45
  39. package/src/__tests__/tween.test.ts +116 -97
  40. package/src/decorators.ts +42 -15
  41. package/src/easings.ts +6 -1
  42. package/src/index.ts +2 -15
  43. package/src/spring.ts +4 -0
  44. package/src/transition.ts +28 -18
  45. package/src/tween.ts +2 -1
  46. package/dist/__tests__/use-motion.test.d.ts +0 -2
  47. package/dist/__tests__/use-motion.test.d.ts.map +0 -1
  48. package/dist/__tests__/use-motion.test.js +0 -139
  49. package/dist/__tests__/use-motion.test.js.map +0 -1
  50. package/dist/use-motion.d.ts +0 -20
  51. package/dist/use-motion.d.ts.map +0 -1
  52. package/dist/use-motion.js +0 -58
  53. package/dist/use-motion.js.map +0 -1
  54. package/src/__tests__/use-motion.test.ts +0 -172
  55. package/src/use-motion.ts +0 -89
@@ -1,60 +1,116 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, it, expect, vi } from "vitest";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
3
 
4
4
  import { spring } from "../spring";
5
5
 
6
- describe("spring", () => {
7
- it("initializes value to the starting number", () => {
8
- vi.useFakeTimers();
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.clearAllTimers();
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ describe("spring()", () => {
16
+ it("starts at the initial value", () => {
9
17
  const s = spring(42);
10
18
  expect(s.value()).toBe(42);
11
- vi.clearAllTimers();
12
- vi.useRealTimers();
19
+ s.stop();
13
20
  });
14
21
 
15
- it("target signal starts at the initial value", () => {
16
- vi.useFakeTimers();
22
+ it("target starts equal to initial value", () => {
17
23
  const s = spring(10);
18
24
  expect(s.target()).toBe(10);
19
- vi.clearAllTimers();
20
- vi.useRealTimers();
25
+ s.stop();
21
26
  });
22
27
 
23
- it("stop() cancels without throwing", () => {
24
- vi.useFakeTimers();
28
+ it("stop() cancels pending animation", () => {
25
29
  const s = spring(0);
26
- expect(() => { s.stop(); }).not.toThrow();
27
- vi.clearAllTimers();
28
- vi.useRealTimers();
30
+ s.target.set(100);
31
+ s.stop();
32
+ const valueAfterStop = s.value();
33
+ expect(typeof valueAfterStop).toBe("number");
29
34
  });
30
35
 
31
- it("stop() can be called multiple times safely", () => {
32
- vi.useFakeTimers();
33
- const s = spring(5);
36
+ it("setting target triggers animation", () => {
37
+ const s = spring(0);
38
+ s.target.set(100);
39
+ expect(s.target()).toBe(100);
34
40
  s.stop();
35
- expect(() => { s.stop(); }).not.toThrow();
36
- vi.clearAllTimers();
37
- vi.useRealTimers();
38
41
  });
39
42
 
40
- it("updating target signal triggers animation", () => {
41
- vi.useFakeTimers();
43
+ it("accepts custom stiffness and damping", () => {
44
+ const s = spring(0, { stiffness: 0.5, damping: 0.9 });
45
+ s.target.set(50);
46
+ expect(s.target()).toBe(50);
47
+ s.stop();
48
+ });
49
+
50
+ it("accepts custom mass and precision", () => {
51
+ const s = spring(0, { mass: 2, precision: 0.01 });
52
+ s.target.set(100);
53
+ expect(typeof s.value()).toBe("number");
54
+ s.stop();
55
+ });
56
+
57
+ it("value is a computed (function)", () => {
42
58
  const s = spring(0);
59
+ expect(typeof s.value).toBe("function");
60
+ s.stop();
61
+ });
62
+
63
+ it("target is a signal with a set method", () => {
64
+ const s = spring(0);
65
+ expect(typeof s.target.set).toBe("function");
66
+ s.stop();
67
+ });
68
+
69
+ it("settting target multiple times accumulates velocity direction", () => {
70
+ const s = spring(0, { stiffness: 0.5, damping: 0.5 });
71
+ s.target.set(10);
72
+ s.target.set(20);
73
+ expect(s.target()).toBe(20);
74
+ s.stop();
75
+ });
76
+
77
+ it("tick advances value toward target", () => {
78
+ const s = spring(0, { stiffness: 0.5, damping: 0.8, precision: 0.01 });
43
79
  s.target.set(100);
44
- // Run a few frames
45
- vi.advanceTimersByTime(100);
46
- // Value should have moved towards 100
80
+ vi.advanceTimersByTime(50);
47
81
  expect(s.value()).toBeGreaterThan(0);
48
82
  s.stop();
49
- vi.clearAllTimers();
50
- vi.useRealTimers();
51
83
  });
52
84
 
53
- it("accepts custom spring options", () => {
54
- vi.useFakeTimers();
55
- const s = spring(0, { stiffness: 0.5, damping: 0.9, mass: 2, precision: 0.01 });
56
- expect(s.value()).toBe(0);
57
- vi.clearAllTimers();
58
- vi.useRealTimers();
85
+ it("moves value closer to target over time", () => {
86
+ const s = spring(0, { stiffness: 0.5, damping: 0.95, precision: 0.01 });
87
+ s.target.set(100);
88
+ vi.advanceTimersByTime(500);
89
+ // Value should have moved significantly toward target
90
+ expect(s.value()).toBeGreaterThan(10);
91
+ });
92
+
93
+ it("stiffness = 0 throws a descriptive error", () => {
94
+ expect(() => spring(0, { stiffness: 0 })).toThrow("stiffness must be greater than 0");
95
+ });
96
+
97
+ it("initial value already equals target — animation stops immediately (no rAF loop)", () => {
98
+ const s = spring(50, { stiffness: 0.5, damping: 0.8, precision: 0.001 });
99
+ // target is also 50 (the default), so the spring should converge instantly
100
+ const initial = s.value();
101
+ vi.advanceTimersByTime(200);
102
+ // Value should remain at (or very close to) 50 — no drift
103
+ expect(s.value()).toBeCloseTo(initial, 3);
104
+ s.stop();
105
+ });
106
+
107
+ it("stop() called while animating — value is frozen and does not continue to change", () => {
108
+ const s = spring(0, { stiffness: 0.5, damping: 0.8 });
109
+ s.target.set(100);
110
+ vi.advanceTimersByTime(50);
111
+ s.stop();
112
+ const valueAfterStop = s.value();
113
+ vi.advanceTimersByTime(500);
114
+ expect(s.value()).toBe(valueAfterStop);
59
115
  });
60
116
  });
@@ -1,75 +1,152 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, it, expect, vi } from "vitest";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
3
 
4
4
  import { createTransition } from "../transition";
5
5
 
6
- describe("createTransition", () => {
7
- it("enter() adds and removes CSS classes", async () => {
8
- vi.useFakeTimers();
9
- const t = createTransition({ name: "fade", duration: 100 });
10
- const el = document.createElement("div");
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
11
9
 
12
- const p = t.enter(el);
10
+ afterEach(() => {
11
+ vi.clearAllTimers();
12
+ vi.useRealTimers();
13
+ });
13
14
 
14
- // enter-from should have been added immediately
15
+ function makeEl() {
16
+ return document.createElement("div");
17
+ }
18
+
19
+ describe("createTransition()", () => {
20
+ it("enter() adds enter-from class immediately", () => {
21
+ const t = createTransition({ name: "fade" });
22
+ const el = makeEl();
23
+ void t.enter(el);
15
24
  expect(el.classList.contains("fade-enter-from")).toBe(true);
25
+ });
16
26
 
17
- // requestAnimationFrame fires
18
- await vi.runAllTimersAsync();
27
+ it("enter() removes enter-from and adds enter-to after rAF", () => {
28
+ const t = createTransition({ name: "fade", duration: 1000 });
29
+ const el = makeEl();
30
+ void t.enter(el);
31
+ // Advance past rAF (16ms) but not past duration (1000ms)
32
+ vi.advanceTimersByTime(20);
33
+ expect(el.classList.contains("fade-enter-from")).toBe(false);
34
+ expect(el.classList.contains("fade-enter-to")).toBe(true);
35
+ });
19
36
 
37
+ it("enter() resolves and cleans up after duration", async () => {
38
+ const t = createTransition({ name: "fade", duration: 300 });
39
+ const el = makeEl();
40
+ const p = t.enter(el);
41
+ vi.advanceTimersByTime(300);
20
42
  await p;
21
- expect(el.classList.contains("fade-enter-from")).toBe(false);
22
43
  expect(el.classList.contains("fade-enter-to")).toBe(false);
23
- vi.useRealTimers();
24
44
  });
25
45
 
26
- it("leave() adds and removes CSS classes", async () => {
27
- vi.useFakeTimers();
28
- const t = createTransition({ name: "slide", duration: 100 });
29
- const el = document.createElement("div");
30
-
31
- const p = t.leave(el);
46
+ it("leave() adds leave-from class immediately", () => {
47
+ const t = createTransition({ name: "slide" });
48
+ const el = makeEl();
49
+ void t.leave(el);
32
50
  expect(el.classList.contains("slide-leave-from")).toBe(true);
51
+ });
33
52
 
34
- await vi.runAllTimersAsync();
35
- await p;
36
-
53
+ it("leave() removes leave-from and adds leave-to after rAF", () => {
54
+ const t = createTransition({ name: "slide", duration: 1000 });
55
+ const el = makeEl();
56
+ void t.leave(el);
57
+ // Advance past rAF (16ms) but not past duration (1000ms)
58
+ vi.advanceTimersByTime(20);
37
59
  expect(el.classList.contains("slide-leave-from")).toBe(false);
60
+ expect(el.classList.contains("slide-leave-to")).toBe(true);
61
+ });
62
+
63
+ it("leave() resolves and cleans up after duration", async () => {
64
+ const t = createTransition({ name: "slide", duration: 200 });
65
+ const el = makeEl();
66
+ const p = t.leave(el);
67
+ vi.advanceTimersByTime(200);
68
+ await p;
38
69
  expect(el.classList.contains("slide-leave-to")).toBe(false);
39
- vi.useRealTimers();
40
70
  });
41
71
 
42
- it("calls onEnter callback", async () => {
43
- vi.useFakeTimers();
72
+ it("uses default name 'transition' when none provided", () => {
73
+ const t = createTransition();
74
+ const el = makeEl();
75
+ void t.enter(el);
76
+ expect(el.classList.contains("transition-enter-from")).toBe(true);
77
+ });
78
+
79
+ it("calls onEnter callback with element", () => {
44
80
  const onEnter = vi.fn();
45
- const t = createTransition({ onEnter, duration: 0 });
46
- const el = document.createElement("div");
47
- const p = t.enter(el);
81
+ const t = createTransition({ onEnter });
82
+ const el = makeEl();
83
+ void t.enter(el);
48
84
  expect(onEnter).toHaveBeenCalledWith(el);
49
- await vi.runAllTimersAsync();
50
- await p;
51
- vi.useRealTimers();
52
85
  });
53
86
 
54
- it("calls onLeave callback", async () => {
55
- vi.useFakeTimers();
87
+ it("calls onLeave callback with element", () => {
56
88
  const onLeave = vi.fn();
57
- const t = createTransition({ onLeave, duration: 0 });
58
- const el = document.createElement("div");
59
- const p = t.leave(el);
89
+ const t = createTransition({ onLeave });
90
+ const el = makeEl();
91
+ void t.leave(el);
60
92
  expect(onLeave).toHaveBeenCalledWith(el);
61
- await vi.runAllTimersAsync();
93
+ });
94
+
95
+ it("uses default duration of 300ms", async () => {
96
+ const t = createTransition({ name: "x" });
97
+ const el = makeEl();
98
+ const p = t.enter(el);
99
+ vi.advanceTimersByTime(300);
62
100
  await p;
63
- vi.useRealTimers();
101
+ expect(el.classList.contains("x-enter-to")).toBe(false);
64
102
  });
65
103
 
66
- it("uses 'transition' as default name", async () => {
67
- vi.useFakeTimers();
68
- const t = createTransition();
69
- const el = document.createElement("div");
70
- t.enter(el);
71
- expect(el.classList.contains("transition-enter-from")).toBe(true);
72
- await vi.runAllTimersAsync();
73
- vi.useRealTimers();
104
+ it("enter() interrupted by leave() before completion — no mixed CSS classes left", async () => {
105
+ const t = createTransition({ name: "fade", duration: 300 });
106
+ const el = makeEl();
107
+
108
+ // Start enter but don't resolve it yet
109
+ const enterP = t.enter(el);
110
+ vi.advanceTimersByTime(20); // past rAF, enter-to is now present
111
+
112
+ // Immediately start leave
113
+ const leaveP = t.leave(el);
114
+ vi.advanceTimersByTime(300);
115
+
116
+ await leaveP;
117
+ // Advance well past enter duration too
118
+ vi.advanceTimersByTime(300);
119
+ await enterP;
120
+
121
+ // After both complete, no transition classes should remain
122
+ expect(el.classList.contains("fade-enter-from")).toBe(false);
123
+ expect(el.classList.contains("fade-enter-to")).toBe(false);
124
+ expect(el.classList.contains("fade-leave-from")).toBe(false);
125
+ expect(el.classList.contains("fade-leave-to")).toBe(false);
126
+ });
127
+
128
+ it("onEnter callback throws — promise rejects with that error", async () => {
129
+ const t = createTransition({
130
+ onEnter: () => { throw new Error("enter callback error"); },
131
+ });
132
+ const el = makeEl();
133
+ await expect(t.enter(el)).rejects.toThrow("enter callback error");
134
+ });
135
+
136
+ it("onLeave callback throws — promise rejects with that error", async () => {
137
+ const t = createTransition({
138
+ onLeave: () => { throw new Error("leave callback error"); },
139
+ });
140
+ const el = makeEl();
141
+ await expect(t.leave(el)).rejects.toThrow("leave callback error");
142
+ });
143
+
144
+ it("duration = 0 — resolves immediately without hanging", async () => {
145
+ const t = createTransition({ name: "fast", duration: 0 });
146
+ const el = makeEl();
147
+ const p = t.enter(el);
148
+ vi.advanceTimersByTime(0);
149
+ await p;
150
+ expect(el.classList.contains("fast-enter-to")).toBe(false);
74
151
  });
75
152
  });
@@ -1,136 +1,155 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, it, expect, vi } from "vitest";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
3
 
4
- import { Animate } from "../decorators";
5
4
  import { tween } from "../tween";
6
5
 
7
- // ── tween ─────────────────────────────────────────────────────────────────────
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.clearAllTimers();
12
+ vi.useRealTimers();
13
+ });
8
14
 
9
- describe("tween", () => {
10
- it("starts with the from value", () => {
11
- vi.useFakeTimers();
15
+ function flushRaf(frames = 60) {
16
+ for (let i = 0; i < frames; i++) {
17
+ vi.runAllTicks();
18
+ const cbs = (globalThis as unknown as { __rafCallbacks?: Array<(t: number) => void> }).__rafCallbacks;
19
+ if (cbs?.length) {
20
+ const batch = [...cbs];
21
+ cbs.length = 0;
22
+ batch.forEach((cb) => cb(performance.now()));
23
+ }
24
+ }
25
+ }
26
+
27
+ describe("tween()", () => {
28
+ it("starts at the `from` value", () => {
12
29
  const t = tween(0, 100);
13
30
  expect(t.value()).toBe(0);
14
- vi.clearAllTimers();
15
- vi.useRealTimers();
31
+ t.stop();
16
32
  });
17
33
 
18
- it("target starts at the to value", () => {
19
- vi.useFakeTimers();
34
+ it("exposes the target signal", () => {
20
35
  const t = tween(0, 100);
21
36
  expect(t.target()).toBe(100);
22
- vi.clearAllTimers();
23
- vi.useRealTimers();
37
+ t.stop();
24
38
  });
25
39
 
26
- it("playing is initially true (animation started immediately)", () => {
27
- vi.useFakeTimers();
40
+ it("playing is true when animating", () => {
28
41
  const t = tween(0, 100);
29
42
  expect(t.playing()).toBe(true);
30
- vi.clearAllTimers();
31
- vi.useRealTimers();
43
+ t.stop();
32
44
  });
33
45
 
34
- it("stop() sets playing to false", () => {
35
- vi.useFakeTimers();
46
+ it("stop() halts animation and sets playing to false", () => {
36
47
  const t = tween(0, 100);
37
48
  t.stop();
38
49
  expect(t.playing()).toBe(false);
39
- vi.clearAllTimers();
40
- vi.useRealTimers();
41
50
  });
42
51
 
43
- it("reset() restores to from value and clears progress", () => {
44
- vi.useFakeTimers();
45
- const t = tween(5, 100);
52
+ it("reset() returns value to `from` and resets progress", () => {
53
+ const t = tween(0, 100);
54
+ t.stop();
46
55
  t.reset();
47
- expect(t.value()).toBe(5);
56
+ expect(t.value()).toBe(0);
48
57
  expect(t.progress()).toBe(0);
49
- vi.clearAllTimers();
50
- vi.useRealTimers();
51
58
  });
52
59
 
53
- it("progress moves towards 1 as animation advances", () => {
54
- vi.useFakeTimers();
55
- const t = tween(0, 100, { duration: 300 });
56
- vi.advanceTimersByTime(300);
57
- // After full duration, value should be near the target
58
- expect(t.progress()).toBeGreaterThanOrEqual(0);
59
- vi.clearAllTimers();
60
- vi.useRealTimers();
60
+ it("progress starts at 0", () => {
61
+ const t = tween(0, 100);
62
+ expect(t.progress()).toBe(0);
63
+ t.stop();
64
+ });
65
+
66
+ it("accepts custom easing as function", () => {
67
+ const t = tween(0, 100, { easing: (x) => x, duration: 300 });
68
+ expect(t.value()).toBe(0);
69
+ t.stop();
70
+ });
71
+
72
+ it("accepts custom duration and delay", () => {
73
+ const t = tween(0, 100, { duration: 500, delay: 100 });
74
+ expect(t.value()).toBe(0);
75
+ t.stop();
61
76
  });
62
77
 
63
- it("changing target restarts animation", () => {
64
- vi.useFakeTimers();
78
+ it("setting target to same value re-triggers start", () => {
65
79
  const t = tween(0, 50);
80
+ const before = t.value();
81
+ t.target.set(50);
82
+ expect(t.value()).toBe(before);
83
+ t.stop();
84
+ });
85
+
86
+ it("changing target while playing updates target", () => {
87
+ const t = tween(0, 100);
66
88
  t.target.set(200);
67
- expect(t.playing()).toBe(true);
89
+ expect(t.target()).toBe(200);
90
+ t.stop();
91
+ });
92
+
93
+ it("animate loop runs and updates value toward target", () => {
94
+ const t = tween(0, 100, { duration: 100, easing: "linear" });
95
+ // Advance through multiple rAF frames
96
+ vi.advanceTimersByTime(50);
97
+ // Value should be moving toward 100
98
+ const mid = t.value();
99
+ expect(mid).toBeGreaterThan(0);
100
+ vi.advanceTimersByTime(100);
68
101
  t.stop();
69
- vi.clearAllTimers();
70
- vi.useRealTimers();
71
102
  });
72
103
 
73
- it("accepts custom easing and delay options", () => {
74
- vi.useFakeTimers();
75
- const t = tween(0, 100, { easing: "linear", delay: 100, duration: 200 });
104
+ it("completes animation and stops playing", () => {
105
+ const t = tween(0, 100, { duration: 100 });
106
+ vi.advanceTimersByTime(500);
107
+ // After several durations, value should be at or near target
108
+ expect(t.value()).toBeGreaterThan(50);
109
+ });
110
+
111
+ it("delay defers animation start", () => {
112
+ const t = tween(0, 100, { duration: 100, delay: 50 });
113
+ vi.advanceTimersByTime(10);
114
+ // Within delay, value stays at 0
76
115
  expect(t.value()).toBe(0);
77
- vi.clearAllTimers();
78
- vi.useRealTimers();
116
+ t.stop();
79
117
  });
80
- });
81
118
 
82
- // ── Animate decorator ─────────────────────────────────────────────────────────
83
-
84
- describe("Animate decorator", () => {
85
- function makeCtx(name: string) {
86
- const initializers: Array<(this: unknown) => void> = [];
87
- return {
88
- ctx: {
89
- name,
90
- kind: "field" as const,
91
- addInitializer(fn: (this: unknown) => void) {
92
- initializers.push(fn);
93
- },
94
- } as ClassFieldDecoratorContext,
95
- run(instance: unknown) {
96
- initializers.forEach((fn) => { fn.call(instance); });
97
- },
98
- };
99
- }
119
+ it("duration = 0 resolves immediately with value at `to`", () => {
120
+ // duration is clamped to 1ms minimum; after a single rAF the tween should complete
121
+ const t = tween(0, 100, { duration: 0, easing: "linear" });
122
+ vi.advanceTimersByTime(50);
123
+ expect(t.value()).toBe(100);
124
+ expect(t.playing()).toBe(false);
125
+ });
126
+
127
+ it("from === to — resolves immediately without animating", () => {
128
+ const t = tween(42, 42, { duration: 300, easing: "linear" });
129
+ // Even before any rAF fires the start value equals the target
130
+ expect(t.value()).toBe(42);
131
+ vi.advanceTimersByTime(300);
132
+ expect(t.value()).toBe(42);
133
+ t.stop();
134
+ });
100
135
 
101
- it("creates a numeric getter/setter on first assignment", () => {
102
- vi.useFakeTimers();
103
- const { ctx, run } = makeCtx("x");
104
- Animate()(undefined, ctx);
105
- const instance: Record<string, unknown> = {};
106
- run(instance);
107
- instance.x = 10;
108
- expect(typeof instance.x).toBe("number");
109
- vi.clearAllTimers();
110
- vi.useRealTimers();
111
- });
112
-
113
- it("returns 0 before any value is set", () => {
114
- vi.useFakeTimers();
115
- const { ctx, run } = makeCtx("opacity");
116
- Animate()(undefined, ctx);
117
- const instance: Record<string, unknown> = {};
118
- run(instance);
119
- expect(instance.opacity).toBe(0);
120
- vi.clearAllTimers();
121
- vi.useRealTimers();
122
- });
123
-
124
- it("updating the property updates the tween target", () => {
125
- vi.useFakeTimers();
126
- const { ctx, run } = makeCtx("scale");
127
- Animate()(undefined, ctx);
128
- const instance: Record<string, unknown> = {};
129
- run(instance);
130
- instance.scale = 1;
131
- instance.scale = 2; // second set updates tween target
132
- expect(typeof instance.scale).toBe("number");
133
- vi.clearAllTimers();
134
- vi.useRealTimers();
136
+ it("progress() is 0 during delay phase before animation starts", () => {
137
+ const t = tween(0, 100, { duration: 100, delay: 200 });
138
+ vi.advanceTimersByTime(50); // well within delay
139
+ expect(t.progress()).toBe(0);
140
+ t.stop();
141
+ });
142
+
143
+ it("easing function is called with t in [0,1] range only", () => {
144
+ const tValues: number[] = [];
145
+ const captureEasing = (t: number) => { tValues.push(t); return t; };
146
+ const tw = tween(0, 100, { duration: 100, easing: captureEasing });
147
+ vi.advanceTimersByTime(300);
148
+ tw.stop();
149
+ expect(tValues.length).toBeGreaterThan(0);
150
+ for (const t of tValues) {
151
+ expect(t).toBeGreaterThanOrEqual(0);
152
+ expect(t).toBeLessThanOrEqual(1);
153
+ }
135
154
  });
136
155
  });
package/src/decorators.ts CHANGED
@@ -1,21 +1,48 @@
1
+ import { createFieldDecorator, type FieldBinding } from "@praxisjs/decorators";
2
+
3
+ import { spring, type SpringOptions } from "./spring";
1
4
  import { tween, type TweenOptions, type Tween } from "./tween";
2
5
 
3
- export function Animate(options: TweenOptions = {}) {
4
- return function (_value: undefined, context: ClassFieldDecoratorContext): void {
5
- const tweens = new WeakMap<object, Tween>();
6
+ type SpringInstance = ReturnType<typeof spring>;
7
+
8
+ export function Tween(options: TweenOptions = {}) {
9
+ const tweens = new WeakMap<object, Tween>();
6
10
 
7
- context.addInitializer(function (this: unknown) {
8
- Object.defineProperty(this, context.name, {
9
- get(this: object): number {
10
- return tweens.get(this)?.value() ?? 0;
11
+ return createFieldDecorator({
12
+ bind(_instance, _name, _initialValue): FieldBinding {
13
+ return {
14
+ descriptor: {
15
+ get(this: object): number {
16
+ return tweens.get(this)?.value() ?? 0;
17
+ },
18
+ set(this: object, value: number): void {
19
+ if (!tweens.has(this)) tweens.set(this, tween(value, value, options));
20
+ tweens.get(this)?.target.set(value);
21
+ },
11
22
  },
12
- set(this: object, value: number): void {
13
- if (!tweens.has(this)) tweens.set(this, tween(value, value, options));
14
- tweens.get(this)?.target.set(value);
23
+ };
24
+ },
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ }) as unknown as (_value: undefined, context: ClassFieldDecoratorContext<any>) => void;
27
+ }
28
+
29
+ export function Spring(options: SpringOptions = {}) {
30
+ const springs = new WeakMap<object, SpringInstance>();
31
+
32
+ return createFieldDecorator({
33
+ bind(_instance, _name, _initialValue): FieldBinding {
34
+ return {
35
+ descriptor: {
36
+ get(this: object): number {
37
+ return springs.get(this)?.value() ?? 0;
38
+ },
39
+ set(this: object, value: number): void {
40
+ if (!springs.has(this)) springs.set(this, spring(value, options));
41
+ springs.get(this)?.target.set(value);
42
+ },
15
43
  },
16
- enumerable: true,
17
- configurable: true,
18
- });
19
- });
20
- };
44
+ };
45
+ },
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ }) as unknown as (_value: undefined, context: ClassFieldDecoratorContext<any>) => void;
21
48
  }