@caperjs/solid 0.7.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 (52) hide show
  1. package/README.md +191 -0
  2. package/jsx.d.ts +39 -0
  3. package/lib/AnimatedShow.d.ts +25 -0
  4. package/lib/AnimatedShow.d.ts.map +1 -0
  5. package/lib/AnimatedShow.test.d.ts +2 -0
  6. package/lib/AnimatedShow.test.d.ts.map +1 -0
  7. package/lib/Composable.d.ts +39 -0
  8. package/lib/Composable.d.ts.map +1 -0
  9. package/lib/Composable.test.d.ts +2 -0
  10. package/lib/Composable.test.d.ts.map +1 -0
  11. package/lib/animated.d.ts +16 -0
  12. package/lib/animated.d.ts.map +1 -0
  13. package/lib/animated.test.d.ts +2 -0
  14. package/lib/animated.test.d.ts.map +1 -0
  15. package/lib/asComponent.d.ts +19 -0
  16. package/lib/asComponent.d.ts.map +1 -0
  17. package/lib/asComponent.test.d.ts +2 -0
  18. package/lib/asComponent.test.d.ts.map +1 -0
  19. package/lib/caper-plugin-solid.mjs +337 -0
  20. package/lib/caper-plugin-solid.mjs.map +1 -0
  21. package/lib/catalog.d.ts +17 -0
  22. package/lib/catalog.d.ts.map +1 -0
  23. package/lib/index.d.ts +9 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/renderer.d.ts +4 -0
  26. package/lib/renderer.d.ts.map +1 -0
  27. package/lib/renderer.test.d.ts +2 -0
  28. package/lib/renderer.test.d.ts.map +1 -0
  29. package/lib/useTick.d.ts +4 -0
  30. package/lib/useTick.d.ts.map +1 -0
  31. package/lib/useTick.test.d.ts +2 -0
  32. package/lib/useTick.test.d.ts.map +1 -0
  33. package/lib/version.d.ts +2 -0
  34. package/lib/version.d.ts.map +1 -0
  35. package/package.json +66 -0
  36. package/src/AnimatedShow.test.tsx +146 -0
  37. package/src/AnimatedShow.tsx +87 -0
  38. package/src/Composable.test.ts +214 -0
  39. package/src/Composable.ts +84 -0
  40. package/src/animated.test.ts +94 -0
  41. package/src/animated.ts +39 -0
  42. package/src/asComponent.test.ts +168 -0
  43. package/src/asComponent.ts +37 -0
  44. package/src/catalog.ts +28 -0
  45. package/src/index.ts +11 -0
  46. package/src/renderer.test.ts +272 -0
  47. package/src/renderer.ts +155 -0
  48. package/src/useTick.test.ts +28 -0
  49. package/src/useTick.ts +10 -0
  50. package/src/version.ts +1 -0
  51. package/vite.d.mts +10 -0
  52. package/vite.mjs +25 -0
@@ -0,0 +1,146 @@
1
+ import gsap from 'gsap';
2
+ import PixiPlugin from 'gsap/PixiPlugin';
3
+ import * as PIXI from 'pixi.js';
4
+ import { Container } from 'pixi.js';
5
+ import { createSignal } from 'solid-js';
6
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
7
+
8
+ import { AnimatedShow } from './AnimatedShow';
9
+ import { createElement, render } from './renderer';
10
+
11
+ // GSAP normally advances its root timeline from requestAnimationFrame, which
12
+ // happy-dom fires on a real clock — nothing to assert against. Detach the root
13
+ // from the ticker once and step it by hand instead, so every test owns time.
14
+ let now = 0;
15
+
16
+ beforeAll(() => {
17
+ // Caper's GSAPPlugin does this at app bootstrap; nothing bootstraps here, and
18
+ // `AnimatedShow` animates through `pixi: {...}` vars. PixiPlugin looks for
19
+ // `window.PIXI`, which no bundled app has — hence the explicit handover.
20
+ gsap.registerPlugin(PixiPlugin);
21
+ PixiPlugin.registerPIXI(PIXI);
22
+ gsap.ticker.remove(gsap.updateRoot);
23
+ now = gsap.ticker.time;
24
+ });
25
+
26
+ function advance(seconds: number) {
27
+ now += seconds;
28
+ gsap.updateRoot(now);
29
+ }
30
+
31
+ /** Mount an `<AnimatedShow>` into a detached container and hand back the seams. */
32
+ function mount(initial: boolean, props: { enter?: gsap.TweenVars; exit?: gsap.TweenVars } = {}) {
33
+ const stage = createElement('container');
34
+ const [open, setOpen] = createSignal(initial);
35
+
36
+ const dispose = render(
37
+ () => (
38
+ <AnimatedShow when={open} enter={props.enter} exit={props.exit}>
39
+ <sprite />
40
+ </AnimatedShow>
41
+ ),
42
+ stage,
43
+ );
44
+
45
+ const wrapper = stage.children[0] as Container;
46
+ return { stage, wrapper, setOpen, dispose, childCount: () => wrapper.children.length };
47
+ }
48
+
49
+ describe('AnimatedShow', () => {
50
+ it('mounts children and animates them in when it opens', () => {
51
+ const { wrapper, setOpen, childCount, dispose } = mount(false);
52
+ expect(childCount()).toBe(0);
53
+
54
+ setOpen(true);
55
+ expect(childCount()).toBe(1);
56
+ expect(wrapper.alpha).toBe(0);
57
+ expect(wrapper.scale.x).toBeCloseTo(0.85, 5);
58
+
59
+ // Default enter runs for 0.25s.
60
+ advance(0.4);
61
+ expect(wrapper.alpha).toBe(1);
62
+ expect(wrapper.scale.x).toBeCloseTo(1, 5);
63
+ expect(wrapper.scale.y).toBeCloseTo(1, 5);
64
+
65
+ dispose();
66
+ });
67
+
68
+ it('keeps children mounted through the exit, then unmounts them', () => {
69
+ const { wrapper, setOpen, childCount, dispose } = mount(true);
70
+ expect(childCount()).toBe(1);
71
+
72
+ setOpen(false);
73
+ expect(childCount()).toBe(1);
74
+
75
+ // Default exit runs for 0.2s.
76
+ advance(0.1);
77
+ expect(childCount()).toBe(1);
78
+ expect(wrapper.alpha).toBeGreaterThan(0);
79
+ expect(wrapper.alpha).toBeLessThan(1);
80
+
81
+ advance(0.2);
82
+ expect(childCount()).toBe(0);
83
+
84
+ dispose();
85
+ });
86
+
87
+ it('cancels the pending unmount when it re-opens mid-exit', () => {
88
+ const { wrapper, setOpen, childCount, dispose } = mount(true);
89
+
90
+ setOpen(false);
91
+ advance(0.1);
92
+ expect(childCount()).toBe(1);
93
+
94
+ setOpen(true);
95
+ expect(childCount()).toBe(1);
96
+
97
+ // Long past the point the exit would have finished and unmounted.
98
+ advance(0.1);
99
+ expect(childCount()).toBe(1);
100
+ advance(1);
101
+ expect(childCount()).toBe(1);
102
+ expect(wrapper.alpha).toBe(1);
103
+
104
+ dispose();
105
+ });
106
+
107
+ it('disposes cleanly mid-animation', () => {
108
+ const { wrapper, setOpen, dispose } = mount(false);
109
+
110
+ setOpen(true);
111
+ advance(0.1);
112
+ // Still in flight: moved off the start, not yet parked on the end value.
113
+ // (The default `back.out` ease overshoots, so "not 1" is the honest check.)
114
+ const mid = wrapper.alpha;
115
+ expect(mid).toBeGreaterThan(0);
116
+ expect(mid).not.toBe(1);
117
+
118
+ expect(() => dispose()).not.toThrow();
119
+ expect(() => advance(1)).not.toThrow();
120
+ // Cleanup killed the animation, so the node is frozen where it stood.
121
+ expect(wrapper.alpha).toBe(mid);
122
+ });
123
+
124
+ it("runs a custom exit's own onComplete before unmounting", () => {
125
+ let childrenWhenCalled = -1;
126
+ const onComplete = vi.fn(() => {
127
+ childrenWhenCalled = wrapper.children.length;
128
+ });
129
+ const { wrapper, setOpen, childCount, dispose } = mount(true, {
130
+ exit: { pixi: { alpha: 0 }, duration: 0.5, onComplete },
131
+ });
132
+
133
+ setOpen(false);
134
+ advance(0.3);
135
+ expect(onComplete).not.toHaveBeenCalled();
136
+ expect(childCount()).toBe(1);
137
+
138
+ advance(0.3);
139
+ expect(onComplete).toHaveBeenCalledTimes(1);
140
+ expect(childrenWhenCalled).toBe(1);
141
+ expect(childCount()).toBe(0);
142
+ expect(wrapper.alpha).toBe(0);
143
+
144
+ dispose();
145
+ });
146
+ });
@@ -0,0 +1,87 @@
1
+ // `<Show>` unmounts the instant its condition flips, which is fine for a scene
2
+ // graph and terrible for a popup. `<AnimatedShow>` keeps the node on stage until
3
+ // its exit animation finishes, so conditional UI can enter and leave gracefully
4
+ // without the caller writing a single imperative line.
5
+
6
+ import gsap from 'gsap';
7
+ import type { Container } from 'pixi.js';
8
+ import { createEffect, createMemo, createSignal, on, onCleanup, Show, untrack } from 'solid-js';
9
+
10
+ /** Where an entering node starts from, before it animates to `enter`. */
11
+ const ENTER_FROM: gsap.TweenVars = { pixi: { alpha: 0, scale: 0.85 } };
12
+ const ENTER_TO: gsap.TweenVars = { pixi: { alpha: 1, scale: 1 }, duration: 0.25, ease: 'back.out(1.7)' };
13
+ const EXIT_TO: gsap.TweenVars = { pixi: { alpha: 0, scale: 0.85 }, duration: 0.2, ease: 'power2.in' };
14
+
15
+ /**
16
+ * Conditional UI with enter / exit animations.
17
+ *
18
+ * ```tsx
19
+ * <AnimatedShow when={open}>
20
+ * <container>…</container>
21
+ * </AnimatedShow>
22
+ * ```
23
+ *
24
+ * Custom `enter` / `exit` are plain GSAP vars — put target properties in a
25
+ * `pixi: {...}` block (`{ pixi: { y: 20, alpha: 0 }, duration: 0.3 }`).
26
+ * There is no `fallback` — the whole point is that the node outlives the flag.
27
+ *
28
+ * Requires GSAP's PixiPlugin, which caper's `GSAPPlugin` registers during app
29
+ * bootstrap. This package deliberately does not register it itself; outside a
30
+ * booted `Application` (tests, standalone use) call
31
+ * `gsap.registerPlugin(PixiPlugin)` yourself first.
32
+ */
33
+ export function AnimatedShow(props: {
34
+ when: boolean | (() => boolean);
35
+ children: any;
36
+ enter?: gsap.TweenVars;
37
+ exit?: gsap.TweenVars;
38
+ }) {
39
+ const open = createMemo(() => (typeof props.when === 'function' ? props.when() : props.when));
40
+ // Trails `open`: goes true immediately, goes false only once the exit is done.
41
+ const [mounted, setMounted] = createSignal(untrack(open));
42
+
43
+ let wrapper: Container | undefined;
44
+ let animation: gsap.core.Tween | undefined;
45
+
46
+ const stop = () => {
47
+ animation?.kill();
48
+ animation = undefined;
49
+ };
50
+
51
+ createEffect(
52
+ on(
53
+ open,
54
+ (isOpen) => {
55
+ const node = wrapper;
56
+ if (!node) return;
57
+ // Killing first is what makes rapid toggling safe: the exit's
58
+ // `onComplete` can no longer unmount a node that is entering again.
59
+ stop();
60
+
61
+ if (isOpen) {
62
+ setMounted(true);
63
+ gsap.set(node, ENTER_FROM);
64
+ animation = gsap.to(node, props.enter ?? ENTER_TO);
65
+ } else {
66
+ const exit = props.exit ?? EXIT_TO;
67
+ animation = gsap.to(node, {
68
+ ...exit,
69
+ onComplete: () => {
70
+ (exit.onComplete as (() => void) | undefined)?.();
71
+ setMounted(false);
72
+ },
73
+ });
74
+ }
75
+ },
76
+ { defer: true },
77
+ ),
78
+ );
79
+
80
+ onCleanup(stop);
81
+
82
+ return (
83
+ <container ref={(el: Container) => (wrapper = el)}>
84
+ <Show when={mounted()}>{props.children}</Show>
85
+ </container>
86
+ );
87
+ }
@@ -0,0 +1,214 @@
1
+ import { Application } from '@caperjs/core';
2
+ import { Graphics, Sprite } from 'pixi.js';
3
+ import { createSignal } from 'solid-js';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { Composable, ComposableContainer, ComposableScene, type Composes } from './Composable';
7
+ import { createElement, insertNode, spread } from './renderer';
8
+
9
+ /**
10
+ * Caper's `Container` reaches for the Application singleton on `added`,
11
+ * `removed` and `destroy`. Only the members those paths touch are stubbed.
12
+ */
13
+ function stubApplication() {
14
+ const ticker = { add: vi.fn(), remove: vi.fn(), addOnce: vi.fn() };
15
+ const layout = { update: vi.fn(), _updateSize: vi.fn(), updateLayout: vi.fn() };
16
+ const onResize = { connect: vi.fn(() => ({ disconnect: vi.fn() })), disconnect: vi.fn() };
17
+ (Application as any).instance = {
18
+ config: { useLayout: true },
19
+ ticker,
20
+ onResize,
21
+ animation: { killAll: vi.fn() },
22
+ renderer: { layout },
23
+ };
24
+ return { ticker, layout, onResize };
25
+ }
26
+
27
+ beforeEach(() => {
28
+ stubApplication();
29
+ });
30
+
31
+ afterEach(() => {
32
+ (Application as any).instance = undefined;
33
+ });
34
+
35
+ /** A stand-in for the stage — a plain host node, not itself composable. */
36
+ function stage() {
37
+ return createElement('container');
38
+ }
39
+
40
+ class Composed extends ComposableContainer implements Composes {
41
+ public compose() {
42
+ return createElement('sprite');
43
+ }
44
+ }
45
+
46
+ describe('Composable', () => {
47
+ it('mounts compose() on the first add, not at construction', () => {
48
+ const view = new Composed();
49
+ expect(view.children).toEqual([]);
50
+
51
+ stage().addChild(view);
52
+
53
+ expect(view.children).toHaveLength(1);
54
+ expect(view.children[0]).toBeInstanceOf(Sprite);
55
+ });
56
+
57
+ it('mounts once — removing and re-adding does not duplicate the tree', () => {
58
+ const parent = stage();
59
+ const view = new Composed();
60
+
61
+ parent.addChild(view);
62
+ const mounted = view.children[0];
63
+
64
+ parent.removeChild(view);
65
+ parent.addChild(view);
66
+
67
+ expect(view.children).toEqual([mounted]);
68
+ });
69
+
70
+ it('keeps props reactive after mount', () => {
71
+ const [x, setX] = createSignal(0);
72
+
73
+ class Moving extends ComposableContainer implements Composes {
74
+ public compose() {
75
+ const node = createElement('sprite');
76
+ spread(
77
+ node,
78
+ {
79
+ get x() {
80
+ return x();
81
+ },
82
+ },
83
+ true,
84
+ );
85
+ return node;
86
+ }
87
+ }
88
+
89
+ const view = new Moving();
90
+ stage().addChild(view);
91
+
92
+ const node = view.children[0] as Sprite;
93
+ expect(node.x).toBe(0);
94
+
95
+ setX(25);
96
+ expect(node.x).toBe(25);
97
+ });
98
+
99
+ it('disposes the solid root on destroy, so updates stop', () => {
100
+ const [x, setX] = createSignal(0);
101
+
102
+ class Moving extends ComposableContainer implements Composes {
103
+ public compose() {
104
+ const node = createElement('sprite');
105
+ spread(
106
+ node,
107
+ {
108
+ get x() {
109
+ return x();
110
+ },
111
+ },
112
+ true,
113
+ );
114
+ return node;
115
+ }
116
+ }
117
+
118
+ const view = new Moving();
119
+ stage().addChild(view);
120
+
121
+ const node = view.children[0] as Sprite;
122
+ setX(25);
123
+ expect(node.x).toBe(25);
124
+
125
+ expect(() => view.destroy()).not.toThrow();
126
+
127
+ setX(99);
128
+ expect(node.x).toBe(25);
129
+ });
130
+
131
+ it('destroys children without a double-destroy error', () => {
132
+ const view = new Composed();
133
+ stage().addChild(view);
134
+ const node = view.children[0] as Sprite;
135
+
136
+ expect(() => view.destroy({ children: true })).not.toThrow();
137
+ expect(node.destroyed).toBe(true);
138
+ expect(view.destroyed).toBe(true);
139
+ });
140
+
141
+ it('mounts a composed child that a composed parent inserts', () => {
142
+ class Child extends ComposableContainer implements Composes {
143
+ public compose() {
144
+ return createElement('graphics');
145
+ }
146
+ }
147
+
148
+ class Parent extends ComposableContainer implements Composes {
149
+ public compose() {
150
+ const root = createElement('container');
151
+ insertNode(root, new Child());
152
+ return root;
153
+ }
154
+ }
155
+
156
+ const view = new Parent();
157
+ stage().addChild(view);
158
+
159
+ const root = view.children[0];
160
+ const child = root.children[0] as Child;
161
+ expect(child).toBeInstanceOf(Child);
162
+ expect(child.children[0]).toBeInstanceOf(Graphics);
163
+ });
164
+
165
+ it('leaves a subclass without compose() completely alone', () => {
166
+ class Plain extends ComposableContainer {}
167
+
168
+ const view = new Plain();
169
+ expect(() => stage().addChild(view)).not.toThrow();
170
+ expect(view.children).toEqual([]);
171
+ expect(() => view.destroy()).not.toThrow();
172
+ });
173
+
174
+ it('still mounts when a subclass overrides added() without calling super', () => {
175
+ class Rude extends ComposableContainer implements Composes {
176
+ public addedRan = false;
177
+
178
+ public added() {
179
+ this.addedRan = true;
180
+ }
181
+
182
+ public compose() {
183
+ return createElement('sprite');
184
+ }
185
+ }
186
+
187
+ const view = new Rude();
188
+ stage().addChild(view);
189
+
190
+ expect(view.addedRan).toBe(true);
191
+ expect(view.children).toHaveLength(1);
192
+ expect(view.children[0]).toBeInstanceOf(Sprite);
193
+ });
194
+
195
+ it('applies to any display base, including Scene', () => {
196
+ class Screen extends ComposableScene implements Composes {
197
+ public compose() {
198
+ return createElement('sprite');
199
+ }
200
+ }
201
+
202
+ const view = new Screen();
203
+ stage().addChild(view);
204
+
205
+ expect(view.children).toHaveLength(1);
206
+ // `Scene.destroy()` takes no arguments — the mixin has to mirror that.
207
+ expect(() => view.destroy()).not.toThrow();
208
+ });
209
+
210
+ it('is exported as a mixin usable on other bases', () => {
211
+ expect(typeof Composable).toBe('function');
212
+ expect(new (Composable(ComposableContainer))()).toBeInstanceOf(ComposableContainer);
213
+ });
214
+ });
@@ -0,0 +1,84 @@
1
+ // The `compose()` contract: a display object declares its members by returning
2
+ // JSX, that tree is mounted ONCE the first time the object hits the stage, and
3
+ // signals do every update after that — no re-render, no diff.
4
+
5
+ import { Container, Constructor, Scene } from '@caperjs/core';
6
+ import type { DestroyOptions } from 'pixi.js';
7
+
8
+ import type { PixiNode } from './renderer';
9
+ import { render } from './renderer';
10
+
11
+ /**
12
+ * What `compose()` hands back. `unknown` for now so this file stays JSX-free;
13
+ * it becomes the real element type when `jsx.d.ts` lands.
14
+ */
15
+ export type Composed = unknown;
16
+
17
+ /**
18
+ * The optional contract a `Composable` subclass opts into. Declared separately
19
+ * from the mixin (rather than merged onto it) so the mixin never claims a
20
+ * `compose` member of its own — the runtime just checks for one.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * class HealthBar extends ComposableContainer implements Composes {
25
+ * compose() {
26
+ * return <text text="100 HP" />;
27
+ * }
28
+ * }
29
+ * ```
30
+ */
31
+ export interface Composes {
32
+ compose(): Composed;
33
+ }
34
+
35
+ /**
36
+ * Give a display class the `compose()` contract.
37
+ *
38
+ * The mixin adds no public members, so the returned constructor keeps `Base`'s
39
+ * own type — `compose()` is declared by the subclass via {@link Composes}.
40
+ *
41
+ * @param Base - The display class to make composable.
42
+ */
43
+ export function Composable<TBase extends Constructor<any>>(Base: TBase): TBase {
44
+ return class extends Base {
45
+ private __composeDispose?: () => void;
46
+ private __composed = false;
47
+
48
+ constructor(...args: any[]) {
49
+ super(...args);
50
+ // Mount off Pixi's native 'added' event rather than the `added()` hook:
51
+ // subclasses routinely override `added()` without calling `super.added()`,
52
+ // which would silently skip the mount. Core's own lifecycle listens to the
53
+ // same event, so this coexists with it (and runs after it).
54
+ this.once('added', this.__mountCompose);
55
+ }
56
+
57
+ destroy(options?: DestroyOptions): void {
58
+ // Dispose the reactive graph first. Solid's disposal stops effects but does
59
+ // not detach the nodes it created — `super.destroy()` handles those, so
60
+ // there is no double-destroy path here.
61
+ this.__composeDispose?.();
62
+ this.__composeDispose = undefined;
63
+ super.destroy(options);
64
+ }
65
+
66
+ private __mountCompose() {
67
+ if (this.__composed) return;
68
+ this.__composed = true;
69
+ const host = this as Partial<Composes>;
70
+ if (typeof host.compose !== 'function') return;
71
+ // `Base` is only known as `Constructor<any>` here, so `this` has to be
72
+ // asserted into the renderer's host-node type.
73
+ this.__composeDispose = render(() => host.compose!() as PixiNode, this as unknown as PixiNode);
74
+ }
75
+ } as unknown as TBase;
76
+ }
77
+
78
+ /** A caper {@link Container} that can declare its members with JSX. */
79
+ export const ComposableContainer = Composable(Container);
80
+ export type ComposableContainer = InstanceType<typeof ComposableContainer>;
81
+
82
+ /** A caper {@link Scene} that can declare its members with JSX. */
83
+ export const ComposableScene = Composable(Scene);
84
+ export type ComposableScene = InstanceType<typeof ComposableScene>;
@@ -0,0 +1,94 @@
1
+ import gsap from 'gsap';
2
+ import { createRoot, createSignal } from 'solid-js';
3
+ import { beforeAll, describe, expect, it } from 'vitest';
4
+
5
+ import { animated } from './animated';
6
+
7
+ // GSAP normally advances its root timeline from requestAnimationFrame, which
8
+ // happy-dom fires on a real clock — nothing to assert against. Detach the root
9
+ // from the ticker once and step it by hand instead, so every test owns time.
10
+ let now = 0;
11
+
12
+ beforeAll(() => {
13
+ gsap.ticker.remove(gsap.updateRoot);
14
+ now = gsap.ticker.time;
15
+ });
16
+
17
+ function advance(seconds: number) {
18
+ now += seconds;
19
+ gsap.updateRoot(now);
20
+ }
21
+
22
+ /**
23
+ * Mount `animated` in its own reactive owner. Solid flushes effects only once
24
+ * the root body returns, so the source has to be driven from outside — inside,
25
+ * the deferred effect would not be listening yet.
26
+ */
27
+ function mount(initial: number, opts?: { duration?: number; ease?: string }) {
28
+ const [source, setSource] = createSignal(initial);
29
+ let shown!: () => number;
30
+ const dispose = createRoot((d) => {
31
+ shown = animated(source, opts);
32
+ return d;
33
+ });
34
+ return { shown: () => shown(), setSource, dispose };
35
+ }
36
+
37
+ describe('animated', () => {
38
+ it('starts at the source value and does not animate on mount', () => {
39
+ const { shown, dispose } = mount(100);
40
+
41
+ expect(shown()).toBe(100);
42
+ advance(1);
43
+ expect(shown()).toBe(100);
44
+
45
+ dispose();
46
+ });
47
+
48
+ it('glides toward a new source value and settles on it', () => {
49
+ const { shown, setSource, dispose } = mount(0);
50
+
51
+ setSource(100);
52
+ // Still at the old value: no frame has rendered yet.
53
+ expect(shown()).toBe(0);
54
+
55
+ advance(0.1);
56
+ const mid = shown();
57
+ expect(mid).toBeGreaterThan(0);
58
+ expect(mid).toBeLessThan(100);
59
+
60
+ // Default duration is 0.35s.
61
+ advance(0.5);
62
+ expect(shown()).toBe(100);
63
+
64
+ dispose();
65
+ });
66
+
67
+ it('honours a custom duration', () => {
68
+ const { shown, setSource, dispose } = mount(0, { duration: 2, ease: 'none' });
69
+
70
+ setSource(100);
71
+
72
+ advance(1);
73
+ expect(shown()).toBeCloseTo(50, 1);
74
+
75
+ advance(1.1);
76
+ expect(shown()).toBe(100);
77
+
78
+ dispose();
79
+ });
80
+
81
+ it('stops animating once its owner is disposed', () => {
82
+ const { shown, setSource, dispose } = mount(0);
83
+
84
+ setSource(100);
85
+ advance(0.1);
86
+ const mid = shown();
87
+ expect(mid).toBeGreaterThan(0);
88
+ expect(mid).toBeLessThan(100);
89
+
90
+ dispose();
91
+ advance(1);
92
+ expect(shown()).toBe(mid);
93
+ });
94
+ });
@@ -0,0 +1,39 @@
1
+ // A "gliding" signal: same read shape as any other accessor, but it eases toward
2
+ // its source instead of snapping to it. The binding stays declarative —
3
+ // `x={slid()}` — and nothing in the view knows an animation is running.
4
+
5
+ import gsap from 'gsap';
6
+ import { createEffect, createSignal, on, onCleanup, untrack } from 'solid-js';
7
+
8
+ /**
9
+ * Follow `source` smoothly.
10
+ *
11
+ * Must be called inside a reactive owner (a component body or a `compose()`),
12
+ * because it registers an `onCleanup` to kill its animation.
13
+ *
14
+ * ```ts
15
+ * const barWidth = animated(() => hp() / 100);
16
+ * <graphics scale={{ x: barWidth(), y: 1 }} />
17
+ * ```
18
+ */
19
+ export function animated(source: () => number, opts?: { duration?: number; ease?: string }): () => number {
20
+ const start = untrack(source);
21
+ const [value, setValue] = createSignal(start);
22
+
23
+ // gsap animates plain object properties, so the accessor's current value lives
24
+ // on this proxy and is mirrored into the signal on every frame.
25
+ const proxy = { v: start };
26
+
27
+ const animateTo = gsap.quickTo(proxy, 'v', {
28
+ duration: opts?.duration ?? 0.35,
29
+ ease: opts?.ease ?? 'power2.out',
30
+ onUpdate: () => setValue(proxy.v),
31
+ });
32
+
33
+ // `defer: true` skips the initial run, so mounting doesn't animate from nowhere.
34
+ createEffect(on(source, (next) => animateTo(next), { defer: true }));
35
+
36
+ onCleanup(() => animateTo.tween.kill());
37
+
38
+ return value;
39
+ }