@vysmo/text 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 +429 -0
- package/dist/animate.d.ts +28 -0
- package/dist/animate.d.ts.map +1 -0
- package/dist/animate.js +418 -0
- package/dist/animate.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/presets/emphasis.d.ts +11 -0
- package/dist/presets/emphasis.d.ts.map +1 -0
- package/dist/presets/emphasis.js +54 -0
- package/dist/presets/emphasis.js.map +1 -0
- package/dist/presets/enter.d.ts +8 -0
- package/dist/presets/enter.d.ts.map +1 -0
- package/dist/presets/enter.js +58 -0
- package/dist/presets/enter.js.map +1 -0
- package/dist/presets/exit.d.ts +5 -0
- package/dist/presets/exit.d.ts.map +1 -0
- package/dist/presets/exit.js +30 -0
- package/dist/presets/exit.js.map +1 -0
- package/dist/presets/generated.d.ts +240 -0
- package/dist/presets/generated.d.ts.map +1 -0
- package/dist/presets/generated.js +3084 -0
- package/dist/presets/generated.js.map +1 -0
- package/dist/presets/index.d.ts +15 -0
- package/dist/presets/index.d.ts.map +1 -0
- package/dist/presets/index.js +43 -0
- package/dist/presets/index.js.map +1 -0
- package/dist/properties.d.ts +10 -0
- package/dist/properties.d.ts.map +1 -0
- package/dist/properties.js +80 -0
- package/dist/properties.js.map +1 -0
- package/dist/split.d.ts +21 -0
- package/dist/split.d.ts.map +1 -0
- package/dist/split.js +171 -0
- package/dist/split.js.map +1 -0
- package/dist/stagger.d.ts +13 -0
- package/dist/stagger.d.ts.map +1 -0
- package/dist/stagger.js +53 -0
- package/dist/stagger.js.map +1 -0
- package/dist/types.d.ts +204 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -0
- package/src/__tests__/animate.test.ts +638 -0
- package/src/__tests__/presets.test.ts +87 -0
- package/src/__tests__/properties.test.ts +62 -0
- package/src/__tests__/split.test.ts +140 -0
- package/src/__tests__/ssr.test.ts +48 -0
- package/src/__tests__/stagger.test.ts +47 -0
- package/src/__tests__/types-check.ts +80 -0
- package/src/animate.ts +469 -0
- package/src/index.ts +38 -0
- package/src/presets/emphasis.ts +60 -0
- package/src/presets/enter.ts +64 -0
- package/src/presets/exit.ts +33 -0
- package/src/presets/generated.ts +3315 -0
- package/src/presets/index.ts +62 -0
- package/src/properties.ts +78 -0
- package/src/split.ts +180 -0
- package/src/stagger.ts +55 -0
- package/src/types.ts +245 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { createTestScheduler } from "@vysmo/animations";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { animateText, evaluateSpecs } from "../animate.js";
|
|
4
|
+
import { pulse } from "../presets/emphasis.js";
|
|
5
|
+
|
|
6
|
+
let mounted: HTMLElement[] = [];
|
|
7
|
+
|
|
8
|
+
function mount(text: string): HTMLElement {
|
|
9
|
+
const el = document.createElement("p");
|
|
10
|
+
el.textContent = text;
|
|
11
|
+
document.body.appendChild(el);
|
|
12
|
+
mounted.push(el);
|
|
13
|
+
return el;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const el of mounted) el.remove();
|
|
18
|
+
mounted = [];
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("evaluateSpecs", () => {
|
|
22
|
+
it("returns the from-value at t=0 for a delay-0 spec", () => {
|
|
23
|
+
const vals = evaluateSpecs(
|
|
24
|
+
[{ prop: "opacity", from: 0, to: 1, duration: 500 }],
|
|
25
|
+
0,
|
|
26
|
+
);
|
|
27
|
+
expect(vals.opacity).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns the to-value at the end of the window", () => {
|
|
31
|
+
const vals = evaluateSpecs(
|
|
32
|
+
[{ prop: "opacity", from: 0, to: 1, duration: 500 }],
|
|
33
|
+
500,
|
|
34
|
+
);
|
|
35
|
+
expect(vals.opacity).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("skips specs whose delay hasn't elapsed", () => {
|
|
39
|
+
const vals = evaluateSpecs(
|
|
40
|
+
[
|
|
41
|
+
{ prop: "scale", from: 1, to: 1.25, duration: 200, delay: 0 },
|
|
42
|
+
{ prop: "scale", from: 1.25, to: 1, duration: 250, delay: 200 },
|
|
43
|
+
],
|
|
44
|
+
100,
|
|
45
|
+
);
|
|
46
|
+
expect(vals.scale).toBeCloseTo(1.125, 3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("later-delayed spec wins once active (prop chaining)", () => {
|
|
50
|
+
const vals = evaluateSpecs(
|
|
51
|
+
[
|
|
52
|
+
{ prop: "scale", from: 1, to: 1.25, duration: 200, delay: 0 },
|
|
53
|
+
{ prop: "scale", from: 1.25, to: 1, duration: 250, delay: 200 },
|
|
54
|
+
],
|
|
55
|
+
325,
|
|
56
|
+
);
|
|
57
|
+
// window-local in spec[1]: (325-200)/250 = 0.5 → 1.25 + (1-1.25)*0.5 = 1.125
|
|
58
|
+
expect(vals.scale).toBeCloseTo(1.125, 3);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("both animation windows closed → holds the most-recent to-value", () => {
|
|
62
|
+
const vals = evaluateSpecs(
|
|
63
|
+
[
|
|
64
|
+
{ prop: "scale", from: 1, to: 1.25, duration: 200, delay: 0 },
|
|
65
|
+
{ prop: "scale", from: 1.25, to: 1, duration: 250, delay: 200 },
|
|
66
|
+
],
|
|
67
|
+
1000,
|
|
68
|
+
);
|
|
69
|
+
expect(vals.scale).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("animateText", () => {
|
|
74
|
+
it("splits the element and returns a handle with slice metadata", () => {
|
|
75
|
+
const el = mount("abc");
|
|
76
|
+
const handle = animateText(el, {
|
|
77
|
+
animations: [{ prop: "opacity", from: 0, to: 1, duration: 100 }],
|
|
78
|
+
autoPlay: false,
|
|
79
|
+
respectReducedMotion: false,
|
|
80
|
+
});
|
|
81
|
+
expect(handle.splits.slices).toHaveLength(3);
|
|
82
|
+
expect(handle.splits.original).toBe("abc");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("applies the initial from-state on creation (no flash of final state)", () => {
|
|
86
|
+
const el = mount("Hi");
|
|
87
|
+
const handle = animateText(el, {
|
|
88
|
+
animations: [{ prop: "opacity", from: 0, to: 1, duration: 100 }],
|
|
89
|
+
autoPlay: false,
|
|
90
|
+
respectReducedMotion: false,
|
|
91
|
+
});
|
|
92
|
+
for (const slice of handle.splits.slices) {
|
|
93
|
+
expect(slice.style.opacity).toBe("0");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("drives slices from 0 → 1 via the injected scheduler", async () => {
|
|
98
|
+
const el = mount("Hi");
|
|
99
|
+
const sched = createTestScheduler();
|
|
100
|
+
const handle = animateText(el, {
|
|
101
|
+
animations: [{ prop: "opacity", from: 0, to: 1, duration: 100 }],
|
|
102
|
+
stagger: 0,
|
|
103
|
+
scheduler: sched,
|
|
104
|
+
respectReducedMotion: false,
|
|
105
|
+
});
|
|
106
|
+
sched.tick(0);
|
|
107
|
+
sched.tick(100);
|
|
108
|
+
for (const slice of handle.splits.slices) {
|
|
109
|
+
expect(slice.style.opacity).toBe("1");
|
|
110
|
+
}
|
|
111
|
+
await handle.finished;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stagger delays slices by their index", () => {
|
|
115
|
+
const el = mount("abcd");
|
|
116
|
+
const sched = createTestScheduler();
|
|
117
|
+
const handle = animateText(el, {
|
|
118
|
+
animations: [{ prop: "opacity", from: 0, to: 1, duration: 100 }],
|
|
119
|
+
stagger: 50,
|
|
120
|
+
scheduler: sched,
|
|
121
|
+
respectReducedMotion: false,
|
|
122
|
+
});
|
|
123
|
+
sched.tick(0);
|
|
124
|
+
sched.tick(50);
|
|
125
|
+
// At t=50: slice 0 is halfway (opacity ~0.5), slice 3 hasn't started (opacity 0).
|
|
126
|
+
const first = Number(handle.splits.slices[0]!.style.opacity);
|
|
127
|
+
const last = Number(handle.splits.slices[3]!.style.opacity);
|
|
128
|
+
expect(first).toBeGreaterThan(last);
|
|
129
|
+
expect(last).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("stop() resets slice styles", () => {
|
|
133
|
+
const el = mount("ab");
|
|
134
|
+
const sched = createTestScheduler();
|
|
135
|
+
const handle = animateText(el, {
|
|
136
|
+
animations: [{ prop: "opacity", from: 0, to: 1, duration: 100 }],
|
|
137
|
+
scheduler: sched,
|
|
138
|
+
respectReducedMotion: false,
|
|
139
|
+
});
|
|
140
|
+
sched.tick(0);
|
|
141
|
+
sched.tick(50);
|
|
142
|
+
handle.stop();
|
|
143
|
+
for (const slice of handle.splits.slices) {
|
|
144
|
+
expect(slice.style.opacity).toBe("");
|
|
145
|
+
expect(slice.style.transform).toBe("");
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("preset drives the split mode, stagger, and animations", () => {
|
|
150
|
+
const el = mount("Hi");
|
|
151
|
+
const handle = animateText(el, {
|
|
152
|
+
preset: "enter/fade-up",
|
|
153
|
+
autoPlay: false,
|
|
154
|
+
respectReducedMotion: false,
|
|
155
|
+
});
|
|
156
|
+
expect(handle.splits.mode).toBe("character");
|
|
157
|
+
// fade-up's initial state: opacity 0, translateY 20 — both visible in inline style.
|
|
158
|
+
for (const slice of handle.splits.slices) {
|
|
159
|
+
expect(slice.style.opacity).toBe("0");
|
|
160
|
+
expect(slice.style.transform).toContain("20px");
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("accepts a Preset object directly (tree-shakable path)", async () => {
|
|
165
|
+
const { fadeUp } = await import("../presets/enter.js");
|
|
166
|
+
const el = mount("Hi");
|
|
167
|
+
const handle = animateText(el, {
|
|
168
|
+
preset: fadeUp,
|
|
169
|
+
autoPlay: false,
|
|
170
|
+
respectReducedMotion: false,
|
|
171
|
+
});
|
|
172
|
+
expect(handle.splits.mode).toBe("character");
|
|
173
|
+
for (const slice of handle.splits.slices) {
|
|
174
|
+
expect(slice.style.opacity).toBe("0");
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("no specs + no preset → finished resolves immediately, no work done", async () => {
|
|
179
|
+
const el = mount("Hi");
|
|
180
|
+
const handle = animateText(el, { respectReducedMotion: false });
|
|
181
|
+
await expect(handle.finished).resolves.toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("emphasis/pulse chains two specs on scale", () => {
|
|
185
|
+
// Sanity-check the preset via evaluateSpecs — visually, pulse grows then shrinks.
|
|
186
|
+
const atMid = evaluateSpecs(pulse.animations, 200);
|
|
187
|
+
expect(atMid.scale).toBeCloseTo(1.25, 2);
|
|
188
|
+
const atEnd = evaluateSpecs(pulse.animations, 450);
|
|
189
|
+
expect(atEnd.scale).toBeCloseTo(1, 2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("applies container perspective + perspective-origin + slice transform-origin", () => {
|
|
193
|
+
const el = mount("Hi");
|
|
194
|
+
animateText(el, {
|
|
195
|
+
preset: "enter/flip-x",
|
|
196
|
+
autoPlay: false,
|
|
197
|
+
respectReducedMotion: false,
|
|
198
|
+
});
|
|
199
|
+
expect(el.style.perspective).toBe("800px");
|
|
200
|
+
const firstSlice = el.querySelector<HTMLElement>("[data-text-slice=\"character\"]");
|
|
201
|
+
expect(firstSlice?.style.transformOrigin).toBe("50% 100%");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("stop() restores container perspective and slice transform-origin", () => {
|
|
205
|
+
const el = mount("Hi");
|
|
206
|
+
const handle = animateText(el, {
|
|
207
|
+
preset: "enter/flip-x",
|
|
208
|
+
respectReducedMotion: false,
|
|
209
|
+
autoPlay: false,
|
|
210
|
+
});
|
|
211
|
+
expect(el.style.perspective).toBe("800px");
|
|
212
|
+
handle.stop();
|
|
213
|
+
expect(el.style.perspective).toBe("");
|
|
214
|
+
const firstSlice = el.querySelector<HTMLElement>("[data-text-slice=\"character\"]");
|
|
215
|
+
expect(firstSlice?.style.transformOrigin).toBe("");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("per-spec stagger overrides the root stagger for that one prop", () => {
|
|
219
|
+
const el = mount("abcd");
|
|
220
|
+
const sched = createTestScheduler();
|
|
221
|
+
const handle = animateText(el, {
|
|
222
|
+
animations: [
|
|
223
|
+
{ prop: "opacity", from: 0, to: 1, duration: 100, stagger: 0 },
|
|
224
|
+
{ prop: "translateY", from: 20, to: 0, duration: 100, stagger: 200 },
|
|
225
|
+
],
|
|
226
|
+
stagger: 0,
|
|
227
|
+
scheduler: sched,
|
|
228
|
+
respectReducedMotion: false,
|
|
229
|
+
});
|
|
230
|
+
sched.tick(0);
|
|
231
|
+
sched.tick(100);
|
|
232
|
+
// At t=100: opacity (stagger=0) is fully at 1 on every slice.
|
|
233
|
+
for (const slice of handle.splits.slices) {
|
|
234
|
+
expect(slice.style.opacity).toBe("1");
|
|
235
|
+
}
|
|
236
|
+
// translateY (stagger=200) has only fired on slice 0 so far — slice 3 is
|
|
237
|
+
// still at its from value (20px).
|
|
238
|
+
const first = handle.splits.slices[0]!;
|
|
239
|
+
const last = handle.splits.slices[3]!;
|
|
240
|
+
expect(first.style.transform).toContain("0px, 0px");
|
|
241
|
+
expect(last.style.transform).toContain("0px, 20px");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("repeat: 2 with repeatDelay: 0 plays two cycles back-to-back", async () => {
|
|
245
|
+
const el = mount("Hi");
|
|
246
|
+
const sched = createTestScheduler();
|
|
247
|
+
const completeSpy: number[] = [];
|
|
248
|
+
const handle = animateText(el, {
|
|
249
|
+
animations: [
|
|
250
|
+
{
|
|
251
|
+
prop: "opacity",
|
|
252
|
+
from: 0,
|
|
253
|
+
to: 1,
|
|
254
|
+
duration: 100,
|
|
255
|
+
ease: (t) => {
|
|
256
|
+
if (t === 1) completeSpy.push(1);
|
|
257
|
+
return t;
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
stagger: 0,
|
|
262
|
+
repeat: 2,
|
|
263
|
+
repeatDelay: 0,
|
|
264
|
+
scheduler: sched,
|
|
265
|
+
respectReducedMotion: false,
|
|
266
|
+
});
|
|
267
|
+
sched.tick(0);
|
|
268
|
+
sched.tick(100); // cycle 1 finishes
|
|
269
|
+
await Promise.resolve();
|
|
270
|
+
await Promise.resolve();
|
|
271
|
+
sched.tick(0); // cycle 2 first frame
|
|
272
|
+
sched.tick(100); // cycle 2 finishes
|
|
273
|
+
await expect(handle.finished).resolves.toBeUndefined();
|
|
274
|
+
// Ease hit t=1 at least twice — once per cycle.
|
|
275
|
+
expect(completeSpy.length).toBeGreaterThanOrEqual(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("repeat: 'infinite' does not resolve finished; stop() does", async () => {
|
|
279
|
+
const el = mount("Hi");
|
|
280
|
+
const sched = createTestScheduler();
|
|
281
|
+
const handle = animateText(el, {
|
|
282
|
+
animations: [{ prop: "opacity", from: 0, to: 1, duration: 50 }],
|
|
283
|
+
stagger: 0,
|
|
284
|
+
repeat: "infinite",
|
|
285
|
+
scheduler: sched,
|
|
286
|
+
respectReducedMotion: false,
|
|
287
|
+
});
|
|
288
|
+
sched.tick(0);
|
|
289
|
+
sched.tick(50); // cycle 1 completes
|
|
290
|
+
await Promise.resolve();
|
|
291
|
+
await Promise.resolve();
|
|
292
|
+
|
|
293
|
+
let done = false;
|
|
294
|
+
handle.finished.then(() => {
|
|
295
|
+
done = true;
|
|
296
|
+
});
|
|
297
|
+
await Promise.resolve();
|
|
298
|
+
expect(done).toBe(false);
|
|
299
|
+
|
|
300
|
+
handle.stop();
|
|
301
|
+
await expect(handle.finished).resolves.toBeUndefined();
|
|
302
|
+
expect(done).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("preset.repeat / preset.repeatDelay are honoured as defaults", () => {
|
|
306
|
+
// Build a tiny preset object without touching the library catalog, so
|
|
307
|
+
// we don't depend on a specific real preset's defaults.
|
|
308
|
+
const handle = animateText(mount("Hi"), {
|
|
309
|
+
preset: {
|
|
310
|
+
name: "emphasis/pulse",
|
|
311
|
+
split: "character",
|
|
312
|
+
stagger: 0,
|
|
313
|
+
repeat: 3,
|
|
314
|
+
repeatDelay: 0,
|
|
315
|
+
animations: [{ prop: "opacity", from: 0.5, to: 1, duration: 50 }],
|
|
316
|
+
},
|
|
317
|
+
autoPlay: false,
|
|
318
|
+
respectReducedMotion: false,
|
|
319
|
+
});
|
|
320
|
+
expect(handle.splits.mode).toBe("character");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("transforms compose rotateX with translate3d when both are active", () => {
|
|
324
|
+
const el = mount("Hi");
|
|
325
|
+
const sched = createTestScheduler();
|
|
326
|
+
const handle = animateText(el, {
|
|
327
|
+
animations: [
|
|
328
|
+
{ prop: "rotateX", from: -90, to: 0, duration: 100 },
|
|
329
|
+
{ prop: "translateY", from: 10, to: 0, duration: 100 },
|
|
330
|
+
],
|
|
331
|
+
stagger: 0,
|
|
332
|
+
scheduler: sched,
|
|
333
|
+
respectReducedMotion: false,
|
|
334
|
+
});
|
|
335
|
+
sched.tick(0);
|
|
336
|
+
sched.tick(50);
|
|
337
|
+
const t = handle.splits.slices[0]!.style.transform;
|
|
338
|
+
expect(t).toContain("translate3d(");
|
|
339
|
+
expect(t).toContain("rotateX(");
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/** Counter-style rng — every call returns a fixed sequence (mod len). */
|
|
344
|
+
function makeSeqRng(values: number[]): () => number {
|
|
345
|
+
let i = 0;
|
|
346
|
+
return () => values[i++ % values.length]!;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
describe("range from / to (per-slice scatter)", () => {
|
|
350
|
+
it("range `from` resolves a different value per slice via rng", () => {
|
|
351
|
+
const el = mount("ABCD");
|
|
352
|
+
// Four slices × (from, to) pairs. Since `to` is scalar (0), only the
|
|
353
|
+
// `from` reads draw from the rng. Stagger is also 0 so it skips its draw.
|
|
354
|
+
const rng = makeSeqRng([0, 0.25, 0.5, 0.75]);
|
|
355
|
+
const handle = animateText(el, {
|
|
356
|
+
animations: [
|
|
357
|
+
{ prop: "translateY", from: { min: 0, max: 100 }, to: 0, duration: 100 },
|
|
358
|
+
],
|
|
359
|
+
stagger: 0,
|
|
360
|
+
rng,
|
|
361
|
+
autoPlay: false,
|
|
362
|
+
respectReducedMotion: false,
|
|
363
|
+
});
|
|
364
|
+
// Initial render is at masterT=0 → progress=0 → each slice shows its `from`.
|
|
365
|
+
const slices = handle.splits.slices;
|
|
366
|
+
expect(slices[0]!.style.transform).toContain("0px, 0px, 0px");
|
|
367
|
+
expect(slices[1]!.style.transform).toContain("0px, 25px, 0px");
|
|
368
|
+
expect(slices[2]!.style.transform).toContain("0px, 50px, 0px");
|
|
369
|
+
expect(slices[3]!.style.transform).toContain("0px, 75px, 0px");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("range with min === max produces a constant without consuming rng draws", () => {
|
|
373
|
+
const el = mount("AB");
|
|
374
|
+
let rngCalls = 0;
|
|
375
|
+
const rng = (): number => {
|
|
376
|
+
rngCalls++;
|
|
377
|
+
return 0.5;
|
|
378
|
+
};
|
|
379
|
+
animateText(el, {
|
|
380
|
+
animations: [
|
|
381
|
+
{ prop: "translateY", from: { min: 5, max: 5 }, to: 0, duration: 100 },
|
|
382
|
+
],
|
|
383
|
+
stagger: 0,
|
|
384
|
+
rng,
|
|
385
|
+
autoPlay: false,
|
|
386
|
+
respectReducedMotion: false,
|
|
387
|
+
});
|
|
388
|
+
// Stagger is 0 so it shouldn't draw. min===max should not draw either.
|
|
389
|
+
expect(rngCalls).toBe(0);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("scalar `from` continues to work (regression)", () => {
|
|
393
|
+
const el = mount("AB");
|
|
394
|
+
const handle = animateText(el, {
|
|
395
|
+
animations: [{ prop: "translateY", from: 20, to: 0, duration: 100 }],
|
|
396
|
+
stagger: 0,
|
|
397
|
+
autoPlay: false,
|
|
398
|
+
respectReducedMotion: false,
|
|
399
|
+
});
|
|
400
|
+
for (const slice of handle.splits.slices) {
|
|
401
|
+
expect(slice.style.transform).toContain("0px, 20px, 0px");
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("range `to` resolves per slice and animates toward that value", () => {
|
|
406
|
+
const el = mount("AB");
|
|
407
|
+
const rng = makeSeqRng([0, 1]);
|
|
408
|
+
const sched = createTestScheduler();
|
|
409
|
+
const handle = animateText(el, {
|
|
410
|
+
animations: [
|
|
411
|
+
{ prop: "translateY", from: 0, to: { min: -50, max: 50 }, duration: 100 },
|
|
412
|
+
],
|
|
413
|
+
stagger: 0,
|
|
414
|
+
scheduler: sched,
|
|
415
|
+
rng,
|
|
416
|
+
respectReducedMotion: false,
|
|
417
|
+
});
|
|
418
|
+
sched.tick(0);
|
|
419
|
+
sched.tick(100); // animation complete — each slice lands at its resolved `to`
|
|
420
|
+
expect(handle.splits.slices[0]!.style.transform).toContain("0px, -50px, 0px");
|
|
421
|
+
expect(handle.splits.slices[1]!.style.transform).toContain("0px, 50px, 0px");
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("jitterDelay (per-slice random delay)", () => {
|
|
426
|
+
it("jitterDelay shifts each slice's effective start time by [0, jitterDelay]", () => {
|
|
427
|
+
const el = mount("AB");
|
|
428
|
+
// Slice 0 jitter = 0 → starts at t=0; slice 1 jitter = 100 → starts at t=100.
|
|
429
|
+
// With duration=100, at t=100: slice 0 is at end (1), slice 1 is at start (0).
|
|
430
|
+
const rng = makeSeqRng([0, 1]);
|
|
431
|
+
const sched = createTestScheduler();
|
|
432
|
+
const handle = animateText(el, {
|
|
433
|
+
animations: [
|
|
434
|
+
{ prop: "opacity", from: 0, to: 1, duration: 100, jitterDelay: 100 },
|
|
435
|
+
],
|
|
436
|
+
stagger: 0,
|
|
437
|
+
scheduler: sched,
|
|
438
|
+
rng,
|
|
439
|
+
respectReducedMotion: false,
|
|
440
|
+
});
|
|
441
|
+
sched.tick(0);
|
|
442
|
+
sched.tick(100);
|
|
443
|
+
expect(handle.splits.slices[0]!.style.opacity).toBe("1");
|
|
444
|
+
expect(handle.splits.slices[1]!.style.opacity).toBe("0");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("jitterDelay = 0 (default) draws no rng samples for jitter", () => {
|
|
448
|
+
const el = mount("AB");
|
|
449
|
+
let calls = 0;
|
|
450
|
+
const rng = (): number => {
|
|
451
|
+
calls++;
|
|
452
|
+
return 0;
|
|
453
|
+
};
|
|
454
|
+
animateText(el, {
|
|
455
|
+
animations: [{ prop: "opacity", from: 0, to: 1, duration: 100 }],
|
|
456
|
+
stagger: 0,
|
|
457
|
+
rng,
|
|
458
|
+
autoPlay: false,
|
|
459
|
+
respectReducedMotion: false,
|
|
460
|
+
});
|
|
461
|
+
expect(calls).toBe(0);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("jitterDelay extends totalDuration so the last slice still finishes", async () => {
|
|
465
|
+
const el = mount("AB");
|
|
466
|
+
const rng = makeSeqRng([0, 1]);
|
|
467
|
+
const sched = createTestScheduler();
|
|
468
|
+
const handle = animateText(el, {
|
|
469
|
+
animations: [
|
|
470
|
+
{ prop: "opacity", from: 0, to: 1, duration: 100, jitterDelay: 100 },
|
|
471
|
+
],
|
|
472
|
+
stagger: 0,
|
|
473
|
+
scheduler: sched,
|
|
474
|
+
rng,
|
|
475
|
+
respectReducedMotion: false,
|
|
476
|
+
});
|
|
477
|
+
sched.tick(0);
|
|
478
|
+
sched.tick(200); // jitter (100) + duration (100) — slice 1 should be done
|
|
479
|
+
await handle.finished;
|
|
480
|
+
expect(handle.splits.slices[1]!.style.opacity).toBe("1");
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe("per-spec transformOrigin", () => {
|
|
485
|
+
it("writes the override to the slice's inline style at spec start", () => {
|
|
486
|
+
const el = mount("AB");
|
|
487
|
+
const sched = createTestScheduler();
|
|
488
|
+
const handle = animateText(el, {
|
|
489
|
+
animations: [
|
|
490
|
+
{
|
|
491
|
+
prop: "rotateX",
|
|
492
|
+
from: -90,
|
|
493
|
+
to: 0,
|
|
494
|
+
duration: 100,
|
|
495
|
+
transformOrigin: { x: 0.5, y: 0 },
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
stagger: 0,
|
|
499
|
+
scheduler: sched,
|
|
500
|
+
respectReducedMotion: false,
|
|
501
|
+
});
|
|
502
|
+
sched.tick(0);
|
|
503
|
+
expect(handle.splits.slices[0]!.style.transformOrigin).toBe("50% 0%");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("last-write-wins when two overlapping specs declare different origins", () => {
|
|
507
|
+
const el = mount("A");
|
|
508
|
+
const sched = createTestScheduler();
|
|
509
|
+
const handle = animateText(el, {
|
|
510
|
+
animations: [
|
|
511
|
+
{
|
|
512
|
+
prop: "rotateX",
|
|
513
|
+
from: -90,
|
|
514
|
+
to: 0,
|
|
515
|
+
duration: 200,
|
|
516
|
+
transformOrigin: { x: 0.5, y: 0 },
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
prop: "rotateY",
|
|
520
|
+
from: -90,
|
|
521
|
+
to: 0,
|
|
522
|
+
duration: 200,
|
|
523
|
+
delay: 100,
|
|
524
|
+
transformOrigin: { x: 1, y: 0.5 },
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
stagger: 0,
|
|
528
|
+
scheduler: sched,
|
|
529
|
+
respectReducedMotion: false,
|
|
530
|
+
});
|
|
531
|
+
sched.tick(0);
|
|
532
|
+
sched.tick(50); // only first spec is open
|
|
533
|
+
expect(handle.splits.slices[0]!.style.transformOrigin).toBe("50% 0%");
|
|
534
|
+
sched.tick(150); // second spec's window opened — wins
|
|
535
|
+
expect(handle.splits.slices[0]!.style.transformOrigin).toBe("100% 50%");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("stop() clears slice transform-origin when no preset baseline was set", () => {
|
|
539
|
+
const el = mount("A");
|
|
540
|
+
const sched = createTestScheduler();
|
|
541
|
+
const handle = animateText(el, {
|
|
542
|
+
animations: [
|
|
543
|
+
{
|
|
544
|
+
prop: "rotateX",
|
|
545
|
+
from: -90,
|
|
546
|
+
to: 0,
|
|
547
|
+
duration: 100,
|
|
548
|
+
transformOrigin: { x: 0.5, y: 0 },
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
stagger: 0,
|
|
552
|
+
scheduler: sched,
|
|
553
|
+
respectReducedMotion: false,
|
|
554
|
+
});
|
|
555
|
+
sched.tick(0);
|
|
556
|
+
expect(handle.splits.slices[0]!.style.transformOrigin).toBe("50% 0%");
|
|
557
|
+
handle.stop();
|
|
558
|
+
expect(handle.splits.slices[0]!.style.transformOrigin).toBe("");
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe("per-spec perspective", () => {
|
|
563
|
+
it("writes the override to the container at spec start", () => {
|
|
564
|
+
const el = mount("AB");
|
|
565
|
+
const sched = createTestScheduler();
|
|
566
|
+
animateText(el, {
|
|
567
|
+
animations: [
|
|
568
|
+
{
|
|
569
|
+
prop: "rotateY",
|
|
570
|
+
from: -90,
|
|
571
|
+
to: 0,
|
|
572
|
+
duration: 100,
|
|
573
|
+
perspective: 1200,
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
stagger: 0,
|
|
577
|
+
scheduler: sched,
|
|
578
|
+
respectReducedMotion: false,
|
|
579
|
+
});
|
|
580
|
+
sched.tick(0);
|
|
581
|
+
expect(el.style.perspective).toBe("1200px");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("last-write-wins across overlapping specs", () => {
|
|
585
|
+
const el = mount("A");
|
|
586
|
+
const sched = createTestScheduler();
|
|
587
|
+
animateText(el, {
|
|
588
|
+
perspective: 800, // baseline
|
|
589
|
+
animations: [
|
|
590
|
+
{
|
|
591
|
+
prop: "rotateX",
|
|
592
|
+
from: -90,
|
|
593
|
+
to: 0,
|
|
594
|
+
duration: 200,
|
|
595
|
+
perspective: 1000,
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
prop: "rotateY",
|
|
599
|
+
from: -90,
|
|
600
|
+
to: 0,
|
|
601
|
+
duration: 200,
|
|
602
|
+
delay: 100,
|
|
603
|
+
perspective: 1500,
|
|
604
|
+
},
|
|
605
|
+
],
|
|
606
|
+
stagger: 0,
|
|
607
|
+
scheduler: sched,
|
|
608
|
+
respectReducedMotion: false,
|
|
609
|
+
});
|
|
610
|
+
sched.tick(0);
|
|
611
|
+
expect(el.style.perspective).toBe("1000px"); // first override fired
|
|
612
|
+
sched.tick(150);
|
|
613
|
+
expect(el.style.perspective).toBe("1500px"); // second override wins
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("stop() restores container even after a per-spec override", () => {
|
|
617
|
+
const el = mount("A");
|
|
618
|
+
const sched = createTestScheduler();
|
|
619
|
+
const handle = animateText(el, {
|
|
620
|
+
animations: [
|
|
621
|
+
{
|
|
622
|
+
prop: "rotateY",
|
|
623
|
+
from: -90,
|
|
624
|
+
to: 0,
|
|
625
|
+
duration: 100,
|
|
626
|
+
perspective: 1200,
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
stagger: 0,
|
|
630
|
+
scheduler: sched,
|
|
631
|
+
respectReducedMotion: false,
|
|
632
|
+
});
|
|
633
|
+
sched.tick(0);
|
|
634
|
+
expect(el.style.perspective).toBe("1200px");
|
|
635
|
+
handle.stop();
|
|
636
|
+
expect(el.style.perspective).toBe("");
|
|
637
|
+
});
|
|
638
|
+
});
|