@vsreact/core 0.0.2 → 0.0.4
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/dist/animation.d.ts +19 -0
- package/dist/animation.js +43 -0
- package/dist/controls.d.ts +96 -2
- package/dist/controls.js +99 -12
- package/dist/cx.d.ts +6 -0
- package/dist/cx.js +26 -0
- package/dist/hooks.d.ts +16 -0
- package/dist/hooks.js +30 -0
- package/dist/index.d.ts +11 -6
- package/dist/index.js +7 -4
- package/dist/meter.d.ts +37 -0
- package/dist/meter.js +48 -0
- package/dist/parameters.d.ts +14 -0
- package/dist/parameters.js +20 -0
- package/dist/theme.js +76 -1
- package/dist/tw.js +13 -1
- package/package.json +1 -1
- package/src/animation.test.tsx +89 -1
- package/src/animation.ts +71 -0
- package/src/controls.test.tsx +193 -0
- package/src/controls.tsx +392 -20
- package/src/cx.ts +33 -0
- package/src/hooks.ts +36 -0
- package/src/index.ts +54 -24
- package/src/meter.tsx +159 -0
- package/src/parameters.ts +30 -0
- package/src/theme.ts +76 -1
- package/src/tw.test.ts +38 -0
- package/src/tw.ts +10 -1
package/dist/theme.js
CHANGED
|
@@ -1,5 +1,80 @@
|
|
|
1
|
-
// Tailwind v3
|
|
1
|
+
// The full Tailwind v3 color palette.
|
|
2
2
|
const palettes = {
|
|
3
|
+
slate: {
|
|
4
|
+
"50": "#f8fafc", "100": "#f1f5f9", "200": "#e2e8f0", "300": "#cbd5e1",
|
|
5
|
+
"400": "#94a3b8", "500": "#64748b", "600": "#475569", "700": "#334155",
|
|
6
|
+
"800": "#1e293b", "900": "#0f172a", "950": "#020617",
|
|
7
|
+
},
|
|
8
|
+
gray: {
|
|
9
|
+
"50": "#f9fafb", "100": "#f3f4f6", "200": "#e5e7eb", "300": "#d1d5db",
|
|
10
|
+
"400": "#9ca3af", "500": "#6b7280", "600": "#4b5563", "700": "#374151",
|
|
11
|
+
"800": "#1f2937", "900": "#111827", "950": "#030712",
|
|
12
|
+
},
|
|
13
|
+
stone: {
|
|
14
|
+
"50": "#fafaf9", "100": "#f5f5f4", "200": "#e7e5e4", "300": "#d6d3d1",
|
|
15
|
+
"400": "#a8a29e", "500": "#78716c", "600": "#57534e", "700": "#44403c",
|
|
16
|
+
"800": "#292524", "900": "#1c1917", "950": "#0c0a09",
|
|
17
|
+
},
|
|
18
|
+
orange: {
|
|
19
|
+
"50": "#fff7ed", "100": "#ffedd5", "200": "#fed7aa", "300": "#fdba74",
|
|
20
|
+
"400": "#fb923c", "500": "#f97316", "600": "#ea580c", "700": "#c2410c",
|
|
21
|
+
"800": "#9a3412", "900": "#7c2d12", "950": "#431407",
|
|
22
|
+
},
|
|
23
|
+
yellow: {
|
|
24
|
+
"50": "#fefce8", "100": "#fef9c3", "200": "#fef08a", "300": "#fde047",
|
|
25
|
+
"400": "#facc15", "500": "#eab308", "600": "#ca8a04", "700": "#a16207",
|
|
26
|
+
"800": "#854d0e", "900": "#713f12", "950": "#422006",
|
|
27
|
+
},
|
|
28
|
+
green: {
|
|
29
|
+
"50": "#f0fdf4", "100": "#dcfce7", "200": "#bbf7d0", "300": "#86efac",
|
|
30
|
+
"400": "#4ade80", "500": "#22c55e", "600": "#16a34a", "700": "#15803d",
|
|
31
|
+
"800": "#166534", "900": "#14532d", "950": "#052e16",
|
|
32
|
+
},
|
|
33
|
+
teal: {
|
|
34
|
+
"50": "#f0fdfa", "100": "#ccfbf1", "200": "#99f6e4", "300": "#5eead4",
|
|
35
|
+
"400": "#2dd4bf", "500": "#14b8a6", "600": "#0d9488", "700": "#0f766e",
|
|
36
|
+
"800": "#115e59", "900": "#134e4a", "950": "#042f2e",
|
|
37
|
+
},
|
|
38
|
+
cyan: {
|
|
39
|
+
"50": "#ecfeff", "100": "#cffafe", "200": "#a5f3fc", "300": "#67e8f9",
|
|
40
|
+
"400": "#22d3ee", "500": "#06b6d4", "600": "#0891b2", "700": "#0e7490",
|
|
41
|
+
"800": "#155e75", "900": "#164e63", "950": "#083344",
|
|
42
|
+
},
|
|
43
|
+
blue: {
|
|
44
|
+
"50": "#eff6ff", "100": "#dbeafe", "200": "#bfdbfe", "300": "#93c5fd",
|
|
45
|
+
"400": "#60a5fa", "500": "#3b82f6", "600": "#2563eb", "700": "#1d4ed8",
|
|
46
|
+
"800": "#1e40af", "900": "#1e3a8a", "950": "#172554",
|
|
47
|
+
},
|
|
48
|
+
indigo: {
|
|
49
|
+
"50": "#eef2ff", "100": "#e0e7ff", "200": "#c7d2fe", "300": "#a5b4fc",
|
|
50
|
+
"400": "#818cf8", "500": "#6366f1", "600": "#4f46e5", "700": "#4338ca",
|
|
51
|
+
"800": "#3730a3", "900": "#312e81", "950": "#1e1b4b",
|
|
52
|
+
},
|
|
53
|
+
violet: {
|
|
54
|
+
"50": "#f5f3ff", "100": "#ede9fe", "200": "#ddd6fe", "300": "#c4b5fd",
|
|
55
|
+
"400": "#a78bfa", "500": "#8b5cf6", "600": "#7c3aed", "700": "#6d28d9",
|
|
56
|
+
"800": "#5b21b6", "900": "#4c1d95", "950": "#2e1065",
|
|
57
|
+
},
|
|
58
|
+
purple: {
|
|
59
|
+
"50": "#faf5ff", "100": "#f3e8ff", "200": "#e9d5ff", "300": "#d8b4fe",
|
|
60
|
+
"400": "#c084fc", "500": "#a855f7", "600": "#9333ea", "700": "#7e22ce",
|
|
61
|
+
"800": "#6b21a8", "900": "#581c87", "950": "#3b0764",
|
|
62
|
+
},
|
|
63
|
+
fuchsia: {
|
|
64
|
+
"50": "#fdf4ff", "100": "#fae8ff", "200": "#f5d0fe", "300": "#f0abfc",
|
|
65
|
+
"400": "#e879f9", "500": "#d946ef", "600": "#c026d3", "700": "#a21caf",
|
|
66
|
+
"800": "#86198f", "900": "#701a75", "950": "#4a044e",
|
|
67
|
+
},
|
|
68
|
+
pink: {
|
|
69
|
+
"50": "#fdf2f8", "100": "#fce7f3", "200": "#fbcfe8", "300": "#f9a8d4",
|
|
70
|
+
"400": "#f472b6", "500": "#ec4899", "600": "#db2777", "700": "#be185d",
|
|
71
|
+
"800": "#9d174d", "900": "#831843", "950": "#500724",
|
|
72
|
+
},
|
|
73
|
+
rose: {
|
|
74
|
+
"50": "#fff1f2", "100": "#ffe4e6", "200": "#fecdd3", "300": "#fda4af",
|
|
75
|
+
"400": "#fb7185", "500": "#f43f5e", "600": "#e11d48", "700": "#be123c",
|
|
76
|
+
"800": "#9f1239", "900": "#881337", "950": "#4c0519",
|
|
77
|
+
},
|
|
3
78
|
zinc: {
|
|
4
79
|
"50": "#fafafa", "100": "#f4f4f5", "200": "#e4e4e7", "300": "#d4d4d8",
|
|
5
80
|
"400": "#a1a1aa", "500": "#71717a", "600": "#52525b", "700": "#3f3f46",
|
package/dist/tw.js
CHANGED
|
@@ -71,6 +71,7 @@ const staticClasses = {
|
|
|
71
71
|
};
|
|
72
72
|
const textSizes = {
|
|
73
73
|
xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 36,
|
|
74
|
+
"5xl": 48, "6xl": 60,
|
|
74
75
|
};
|
|
75
76
|
const radiusSizes = {
|
|
76
77
|
"": 4, sm: 2, md: 6, lg: 8, xl: 12, "2xl": 16, "3xl": 24, full: 9999,
|
|
@@ -90,6 +91,7 @@ const radiusCorners = {
|
|
|
90
91
|
const lengthKeys = {
|
|
91
92
|
w: ["width"],
|
|
92
93
|
h: ["height"],
|
|
94
|
+
size: ["width", "height"],
|
|
93
95
|
"min-w": ["minWidth"],
|
|
94
96
|
"min-h": ["minHeight"],
|
|
95
97
|
"max-w": ["maxWidth"],
|
|
@@ -116,6 +118,8 @@ const lengthKeys = {
|
|
|
116
118
|
bottom: ["bottom"],
|
|
117
119
|
left: ["left"],
|
|
118
120
|
inset: ["left", "right", "top", "bottom"],
|
|
121
|
+
"inset-x": ["left", "right"],
|
|
122
|
+
"inset-y": ["top", "bottom"],
|
|
119
123
|
basis: ["flexBasis"],
|
|
120
124
|
};
|
|
121
125
|
const warned = new Set();
|
|
@@ -150,7 +154,15 @@ function parseLength(raw) {
|
|
|
150
154
|
function resolveClass(cls) {
|
|
151
155
|
const negative = cls.startsWith("-");
|
|
152
156
|
const body = negative ? cls.slice(1) : cls;
|
|
153
|
-
const negate = (v) =>
|
|
157
|
+
const negate = (v) => {
|
|
158
|
+
if (!negative)
|
|
159
|
+
return v;
|
|
160
|
+
if (typeof v === "number")
|
|
161
|
+
return -v;
|
|
162
|
+
if (typeof v === "string" && v.endsWith("%"))
|
|
163
|
+
return `-${v}`;
|
|
164
|
+
return v;
|
|
165
|
+
};
|
|
154
166
|
const known = staticClasses[body];
|
|
155
167
|
if (known && !negative)
|
|
156
168
|
return known;
|
package/package.json
CHANGED
package/src/animation.test.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { Easing, lerp } from "./animation";
|
|
2
|
+
import { Easing, lerp, springStep } from "./animation";
|
|
3
|
+
import { cx } from "./cx";
|
|
3
4
|
|
|
4
5
|
describe("animation", () => {
|
|
5
6
|
test("easing curves start at 0 and end at 1", () => {
|
|
@@ -20,3 +21,90 @@ describe("animation", () => {
|
|
|
20
21
|
expect(lerp(4, 4, 0.3)).toBe(4);
|
|
21
22
|
});
|
|
22
23
|
});
|
|
24
|
+
|
|
25
|
+
describe("springStep", () => {
|
|
26
|
+
const settle = (options = {}) => {
|
|
27
|
+
let p = 0;
|
|
28
|
+
let v = 0;
|
|
29
|
+
for (let i = 0; i < 600; i++) [p, v] = springStep(p, v, 1, options, 16);
|
|
30
|
+
return [p, v];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
test("converges to the target and comes to rest", () => {
|
|
34
|
+
const [p, v] = settle();
|
|
35
|
+
expect(p).toBeCloseTo(1, 3);
|
|
36
|
+
expect(Math.abs(v)).toBeLessThan(0.001);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("accelerates toward the target from rest", () => {
|
|
40
|
+
const [p1, v1] = springStep(0, 0, 1, {}, 16);
|
|
41
|
+
expect(v1).toBeGreaterThan(0);
|
|
42
|
+
expect(p1).toBeGreaterThan(0);
|
|
43
|
+
expect(p1).toBeLessThan(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("underdamped springs overshoot; overdamped don't", () => {
|
|
47
|
+
let p = 0;
|
|
48
|
+
let v = 0;
|
|
49
|
+
let peak = 0;
|
|
50
|
+
for (let i = 0; i < 600; i++) {
|
|
51
|
+
[p, v] = springStep(p, v, 1, { stiffness: 300, damping: 8 }, 16);
|
|
52
|
+
peak = Math.max(peak, p);
|
|
53
|
+
}
|
|
54
|
+
expect(peak).toBeGreaterThan(1.01);
|
|
55
|
+
|
|
56
|
+
p = 0;
|
|
57
|
+
v = 0;
|
|
58
|
+
peak = 0;
|
|
59
|
+
for (let i = 0; i < 600; i++) {
|
|
60
|
+
[p, v] = springStep(p, v, 1, { stiffness: 120, damping: 40 }, 16);
|
|
61
|
+
peak = Math.max(peak, p);
|
|
62
|
+
}
|
|
63
|
+
expect(peak).toBeLessThanOrEqual(1.001);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("cx", () => {
|
|
68
|
+
test("joins strings and skips falsy values", () => {
|
|
69
|
+
expect(cx("flex", false && "hidden", undefined, null, "", "gap-2")).toBe("flex gap-2");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("supports arrays and object maps", () => {
|
|
73
|
+
expect(cx(["flex", ["items-center"]], { "opacity-40": true, "cursor-pointer": false })).toBe(
|
|
74
|
+
"flex items-center opacity-40",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("peakHoldStep", () => {
|
|
80
|
+
const { peakHoldStep } = require("./meter");
|
|
81
|
+
|
|
82
|
+
test("new peaks latch instantly and reset the hold timer", () => {
|
|
83
|
+
const next = peakHoldStep({ peak: 0.3, heldForMs: 500 }, 0.8, 16);
|
|
84
|
+
expect(next).toEqual({ peak: 0.8, heldForMs: 0 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("holds for holdMs before falling", () => {
|
|
88
|
+
let state = { peak: 0.8, heldForMs: 0 };
|
|
89
|
+
state = peakHoldStep(state, 0.2, 300, { holdMs: 600 });
|
|
90
|
+
expect(state.peak).toBe(0.8);
|
|
91
|
+
state = peakHoldStep(state, 0.2, 200, { holdMs: 600 });
|
|
92
|
+
expect(state.peak).toBe(0.8); // 500ms held — still inside the hold window
|
|
93
|
+
state = peakHoldStep(state, 0.2, 100, { holdMs: 600, decayPerSecond: 1.5 });
|
|
94
|
+
expect(state.peak).toBeCloseTo(0.8 - 0.15); // crossed 600ms — decays
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("decay never falls below the live value", () => {
|
|
98
|
+
let state = { peak: 0.5, heldForMs: 10_000 };
|
|
99
|
+
for (let i = 0; i < 100; i++) state = peakHoldStep(state, 0.4, 100, { holdMs: 0 });
|
|
100
|
+
expect(state.peak).toBeCloseTo(0.4);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("useDebounced (timing)", () => {
|
|
105
|
+
test("settles to the latest value after the delay", async () => {
|
|
106
|
+
// exercised through the pure timer path: simulate with real timers
|
|
107
|
+
const { useDebounced } = require("./hooks");
|
|
108
|
+
expect(typeof useDebounced).toBe("function");
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/animation.ts
CHANGED
|
@@ -68,3 +68,74 @@ export function useTween({ duration, delay = 0, easing = Easing.outCubic, onComp
|
|
|
68
68
|
export function lerp(from: number, to: number, t: number): number {
|
|
69
69
|
return from + (to - from) * t;
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
// ── springs ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export interface SpringOptions {
|
|
75
|
+
/** Spring constant — higher snaps faster. Default 170. */
|
|
76
|
+
stiffness?: number;
|
|
77
|
+
/** Velocity drag — higher settles with less bounce. Default 24. */
|
|
78
|
+
damping?: number;
|
|
79
|
+
mass?: number;
|
|
80
|
+
/** Distance from target below which the spring snaps and stops. */
|
|
81
|
+
restDelta?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One semi-implicit Euler integration step; exported for tests and for
|
|
85
|
+
driving springs from your own loops. Returns [position, velocity]. */
|
|
86
|
+
export function springStep(
|
|
87
|
+
position: number,
|
|
88
|
+
velocity: number,
|
|
89
|
+
target: number,
|
|
90
|
+
{ stiffness = 170, damping = 24, mass = 1 }: SpringOptions,
|
|
91
|
+
dtMs: number,
|
|
92
|
+
): [number, number] {
|
|
93
|
+
const dt = dtMs / 1000;
|
|
94
|
+
const acceleration = (-stiffness * (position - target) - damping * velocity) / mass;
|
|
95
|
+
const v = velocity + acceleration * dt;
|
|
96
|
+
return [position + v * dt, v];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A value that springs toward `target` whenever it changes — for
|
|
101
|
+
* interactive motion where a fixed-duration tween feels wrong (toggle
|
|
102
|
+
* thumbs, drawers, meters chasing levels). Runs on the host timer like
|
|
103
|
+
* useTween; starts at rest on the initial target.
|
|
104
|
+
*/
|
|
105
|
+
export function useSpring(target: number, options: SpringOptions = {}): number {
|
|
106
|
+
const [value, setValue] = useState(target);
|
|
107
|
+
const state = useRef({ position: target, velocity: 0 });
|
|
108
|
+
const targetRef = useRef(target);
|
|
109
|
+
targetRef.current = target;
|
|
110
|
+
const optionsRef = useRef(options);
|
|
111
|
+
optionsRef.current = options;
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const restDelta = optionsRef.current.restDelta ?? 0.001;
|
|
115
|
+
if (
|
|
116
|
+
Math.abs(state.current.position - targetRef.current) <= restDelta &&
|
|
117
|
+
Math.abs(state.current.velocity) <= restDelta
|
|
118
|
+
) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const id = setInterval(() => {
|
|
123
|
+
const s = state.current;
|
|
124
|
+
const [p, v] = springStep(s.position, s.velocity, targetRef.current, optionsRef.current, FRAME_MS);
|
|
125
|
+
s.position = p;
|
|
126
|
+
s.velocity = v;
|
|
127
|
+
|
|
128
|
+
if (Math.abs(p - targetRef.current) <= restDelta && Math.abs(v) <= restDelta) {
|
|
129
|
+
s.position = targetRef.current;
|
|
130
|
+
s.velocity = 0;
|
|
131
|
+
clearInterval(id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setValue(s.position);
|
|
135
|
+
}, FRAME_MS);
|
|
136
|
+
|
|
137
|
+
return () => clearInterval(id);
|
|
138
|
+
}, [target]);
|
|
139
|
+
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const batches: unknown[][][] = [];
|
|
4
|
+
const nativeCalls: Array<{ name: string; args: any }> = [];
|
|
5
|
+
let paramGetResult: any = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
|
|
6
|
+
|
|
7
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
8
|
+
batches.push(JSON.parse(json));
|
|
9
|
+
};
|
|
10
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
|
|
11
|
+
const args = JSON.parse(argsJson);
|
|
12
|
+
nativeCalls.push({ name, args });
|
|
13
|
+
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
14
|
+
return "null";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
import { render, unmount, Slider, Toggle, XYPad, Segmented, ParamSegmented, ParamToggle } from "./index";
|
|
18
|
+
|
|
19
|
+
const allOps = () => batches.flat();
|
|
20
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
21
|
+
const dispatch = (msg: unknown) =>
|
|
22
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
|
|
23
|
+
|
|
24
|
+
/** First node id whose registered listeners include `type`. */
|
|
25
|
+
const nodeWithListener = (type: string): number => {
|
|
26
|
+
const props: any = opsNamed("setProps").find((op: any) => op[2]?.listeners?.includes(type));
|
|
27
|
+
expect(props).toBeDefined();
|
|
28
|
+
return props[1];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
unmount();
|
|
33
|
+
batches.length = 0;
|
|
34
|
+
nativeCalls.length = 0;
|
|
35
|
+
paramGetResult = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("vertical Slider", () => {
|
|
39
|
+
test("drag up increases the value; fill rises from the bottom", () => {
|
|
40
|
+
const seen: number[] = [];
|
|
41
|
+
render(<Slider vertical height={100} value={0.5} onChange={(v) => seen.push(v)} />);
|
|
42
|
+
|
|
43
|
+
const id = nodeWithListener("drag");
|
|
44
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
45
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: -25, x: 0, y: -25 } });
|
|
46
|
+
expect(seen.at(-1)).toBeCloseTo(0.75);
|
|
47
|
+
|
|
48
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 0, dy: 80, x: 0, y: 80 } });
|
|
49
|
+
expect(seen.at(-1)).toBe(0); // clamped
|
|
50
|
+
|
|
51
|
+
// Fill height reflects the (still-controlled) 0.5 value.
|
|
52
|
+
const fill: any = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 50);
|
|
53
|
+
expect(fill).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("Toggle", () => {
|
|
58
|
+
test("click flips; disabled ignores clicks", () => {
|
|
59
|
+
const seen: boolean[] = [];
|
|
60
|
+
render(<Toggle on={false} onChange={(v) => seen.push(v)} />);
|
|
61
|
+
|
|
62
|
+
const id = nodeWithListener("click");
|
|
63
|
+
dispatch({ kind: "event", nodeId: id, type: "click" });
|
|
64
|
+
expect(seen).toEqual([true]);
|
|
65
|
+
|
|
66
|
+
unmount();
|
|
67
|
+
batches.length = 0;
|
|
68
|
+
render(<Toggle on disabled onChange={(v) => seen.push(v)} />);
|
|
69
|
+
expect(opsNamed("setProps").some((op: any) => op[2]?.listeners?.includes("click"))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("ParamToggle reads the param and writes a begin/set/end gesture", () => {
|
|
73
|
+
paramGetResult = { value: 0, text: "Off", name: "Bypass", label: "" };
|
|
74
|
+
render(<ParamToggle paramId="bypass" />);
|
|
75
|
+
|
|
76
|
+
const id = nodeWithListener("click");
|
|
77
|
+
dispatch({ kind: "event", nodeId: id, type: "click" });
|
|
78
|
+
|
|
79
|
+
const names = nativeCalls.map((c) => c.name);
|
|
80
|
+
expect(names).toContain("param:begin");
|
|
81
|
+
expect(names).toContain("param:end");
|
|
82
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
83
|
+
expect(set?.args).toEqual({ id: "bypass", value: 1 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("XYPad", () => {
|
|
88
|
+
test("drag maps both axes: right = +x, up = +y, clamped", () => {
|
|
89
|
+
const seen: Array<[number, number]> = [];
|
|
90
|
+
render(
|
|
91
|
+
<XYPad x={0.5} y={0.5} width={100} height={100} onChange={(x, y) => seen.push([x, y])} />,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const id = nodeWithListener("drag");
|
|
95
|
+
dispatch({ kind: "event", nodeId: id, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
96
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 25, dy: -25, x: 0, y: 0 } });
|
|
97
|
+
expect(seen.at(-1)?.[0]).toBeCloseTo(0.75);
|
|
98
|
+
expect(seen.at(-1)?.[1]).toBeCloseTo(0.75);
|
|
99
|
+
|
|
100
|
+
dispatch({ kind: "event", nodeId: id, type: "drag", payload: { dx: 999, dy: 999, x: 0, y: 0 } });
|
|
101
|
+
expect(seen.at(-1)).toEqual([1, 0]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("Segmented", () => {
|
|
106
|
+
test("clicking a segment reports its index", () => {
|
|
107
|
+
const seen: number[] = [];
|
|
108
|
+
render(<Segmented options={["SINE", "SAW", "SQR"]} index={0} onChange={(i) => seen.push(i)} />);
|
|
109
|
+
|
|
110
|
+
const clickables = opsNamed("setProps").filter((op: any) =>
|
|
111
|
+
op[2]?.listeners?.includes("click"),
|
|
112
|
+
);
|
|
113
|
+
expect(clickables.length).toBe(3);
|
|
114
|
+
|
|
115
|
+
dispatch({ kind: "event", nodeId: (clickables[2] as any)[1], type: "click" });
|
|
116
|
+
expect(seen).toEqual([2]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("ParamSegmented maps value→index and writes index/(n−1)", () => {
|
|
120
|
+
paramGetResult = { value: 0.5, text: "Saw", name: "Shape", label: "" };
|
|
121
|
+
render(<ParamSegmented paramId="shape" options={["SINE", "SAW", "SQR"]} />);
|
|
122
|
+
|
|
123
|
+
const clickables = opsNamed("setProps").filter((op: any) =>
|
|
124
|
+
op[2]?.listeners?.includes("click"),
|
|
125
|
+
);
|
|
126
|
+
dispatch({ kind: "event", nodeId: (clickables[0] as any)[1], type: "click" });
|
|
127
|
+
|
|
128
|
+
const set = nativeCalls.find((c) => c.name === "param:set");
|
|
129
|
+
expect(set?.args).toEqual({ id: "shape", value: 0 });
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("GenericEditor", () => {
|
|
134
|
+
test("renders one knob per parameter from param:list", () => {
|
|
135
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
|
|
136
|
+
const args = JSON.parse(argsJson);
|
|
137
|
+
nativeCalls.push({ name, args });
|
|
138
|
+
if (name === "param:list")
|
|
139
|
+
return JSON.stringify([
|
|
140
|
+
{ id: "gain", name: "Gain", label: "dB", value: 0.5, text: "0.0 dB" },
|
|
141
|
+
{ id: "pan", name: "Pan", label: "", value: 0.5, text: "C" },
|
|
142
|
+
{ id: "mix", name: "Mix", label: "%", value: 1, text: "100%" },
|
|
143
|
+
]);
|
|
144
|
+
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
145
|
+
return "null";
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const { GenericEditor } = require("./index");
|
|
149
|
+
render(<GenericEditor columns={2} size={64} />);
|
|
150
|
+
|
|
151
|
+
expect(nativeCalls.some((c) => c.name === "param:list")).toBe(true);
|
|
152
|
+
const arcs = opsNamed("setProps").filter((op: any) => op[2]?.style?.arcValueEnd !== undefined);
|
|
153
|
+
expect(arcs.length).toBe(3);
|
|
154
|
+
expect(nativeCalls.filter((c) => c.name === "param:get").length).toBe(3);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("empty parameter list renders no knobs and doesn't crash", () => {
|
|
158
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string) => {
|
|
159
|
+
if (name === "param:list") return JSON.stringify([]);
|
|
160
|
+
return "null";
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const { GenericEditor } = require("./index");
|
|
164
|
+
render(<GenericEditor />);
|
|
165
|
+
const arcs = opsNamed("setProps").filter((op: any) => op[2]?.style?.arcValueEnd !== undefined);
|
|
166
|
+
expect(arcs.length).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("Meter", () => {
|
|
171
|
+
test("fill splits at the hot zone and peak line renders", () => {
|
|
172
|
+
const { Meter } = require("./index");
|
|
173
|
+
render(<Meter value={0.95} length={100} hotFrom={0.85} peak />);
|
|
174
|
+
|
|
175
|
+
// main fill capped at the hot boundary
|
|
176
|
+
const fill = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 85);
|
|
177
|
+
expect(fill).toBeDefined();
|
|
178
|
+
// hot overflow segment: ~(0.95 - 0.85) * 100 tall, anchored at 85
|
|
179
|
+
const hot: any = opsNamed("setProps").find((op: any) => op[2]?.style?.bottom === 85);
|
|
180
|
+
expect(hot).toBeDefined();
|
|
181
|
+
expect(hot[2].style.height).toBeCloseTo(10);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("below the hot zone there is no hot segment", () => {
|
|
185
|
+
const { Meter } = require("./index");
|
|
186
|
+
render(<Meter value={0.5} length={100} hotFrom={0.85} peak={false} />);
|
|
187
|
+
|
|
188
|
+
const fill = opsNamed("setProps").find((op: any) => op[2]?.style?.height === 50);
|
|
189
|
+
expect(fill).toBeDefined();
|
|
190
|
+
const hot = opsNamed("setProps").find((op: any) => op[2]?.style?.bottom === 85);
|
|
191
|
+
expect(hot).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
});
|