@vsreact/core 0.0.10 → 0.0.12
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/build.ts +24 -24
- package/dist/components.d.ts +6 -0
- package/dist/components.js +3 -0
- package/dist/controls.d.ts +7 -2
- package/dist/controls.js +11 -11
- package/dist/feedback.d.ts +4 -1
- package/dist/feedback.js +12 -2
- package/dist/format.d.ts +16 -0
- package/dist/format.js +50 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/keyboard.d.ts +23 -0
- package/dist/keyboard.js +79 -0
- package/dist/sequencer.d.ts +23 -0
- package/dist/sequencer.js +20 -0
- package/dist/specialty.d.ts +109 -0
- package/dist/specialty.js +186 -0
- package/dist/tw.js +5 -0
- package/package.json +64 -64
- package/src/bridge.ts +66 -66
- package/src/components.ts +22 -0
- package/src/controls.test.tsx +109 -0
- package/src/controls.tsx +62 -22
- package/src/feedback.tsx +28 -6
- package/src/format.ts +62 -0
- package/src/hostConfig.test.tsx +120 -120
- package/src/hostConfig.ts +164 -164
- package/src/index.ts +31 -0
- package/src/keyboard.tsx +151 -0
- package/src/native.ts +18 -18
- package/src/perform.test.tsx +188 -0
- package/src/primitives.tsx +95 -95
- package/src/render.tsx +47 -47
- package/src/runtime.ts +92 -92
- package/src/sequencer.tsx +83 -0
- package/src/specialty.tsx +514 -0
- package/src/theme.ts +159 -159
- package/src/tw.test.ts +119 -113
- package/src/tw.ts +288 -284
package/src/controls.tsx
CHANGED
|
@@ -152,6 +152,8 @@ export interface SliderProps {
|
|
|
152
152
|
height?: number;
|
|
153
153
|
/** Vertical fader — drag up for more, fill rises from the bottom. */
|
|
154
154
|
vertical?: boolean;
|
|
155
|
+
/** Flat hardware-style bar thumb instead of the dot. */
|
|
156
|
+
barThumb?: boolean;
|
|
155
157
|
label?: string;
|
|
156
158
|
disabled?: boolean;
|
|
157
159
|
/** Double-click resets to this (DAW convention). */
|
|
@@ -170,6 +172,7 @@ export function Slider({
|
|
|
170
172
|
width = 160,
|
|
171
173
|
height = 160,
|
|
172
174
|
vertical,
|
|
175
|
+
barThumb,
|
|
173
176
|
label,
|
|
174
177
|
disabled,
|
|
175
178
|
defaultValue,
|
|
@@ -223,10 +226,17 @@ export function Slider({
|
|
|
223
226
|
className="absolute w-[4] rounded-full left-[7] bottom-0"
|
|
224
227
|
style={{ height: clamped * height, backgroundColor: valueColor }}
|
|
225
228
|
/>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
229
|
+
{barThumb ? (
|
|
230
|
+
<View
|
|
231
|
+
className="absolute w-[18] h-[5] rounded-[2] left-0"
|
|
232
|
+
style={{ top: (1 - clamped) * (height - 5), backgroundColor: valueColor }}
|
|
233
|
+
/>
|
|
234
|
+
) : (
|
|
235
|
+
<View
|
|
236
|
+
className="absolute w-[12] h-[12] rounded-full left-[3]"
|
|
237
|
+
style={{ top: (1 - clamped) * (height - 12), backgroundColor: valueColor }}
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
230
240
|
</View>
|
|
231
241
|
{label !== undefined ? (
|
|
232
242
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
@@ -251,10 +261,17 @@ export function Slider({
|
|
|
251
261
|
className="absolute h-[4] rounded-full top-[7]"
|
|
252
262
|
style={{ width: clamped * width, backgroundColor: valueColor }}
|
|
253
263
|
/>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
264
|
+
{barThumb ? (
|
|
265
|
+
<View
|
|
266
|
+
className="absolute w-[5] h-[18] rounded-[2] top-0"
|
|
267
|
+
style={{ left: clamped * (width - 5), backgroundColor: valueColor }}
|
|
268
|
+
/>
|
|
269
|
+
) : (
|
|
270
|
+
<View
|
|
271
|
+
className="absolute w-[12] h-[12] rounded-full top-[3]"
|
|
272
|
+
style={{ left: clamped * (width - 12), backgroundColor: valueColor }}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
258
275
|
</View>
|
|
259
276
|
{label !== undefined ? (
|
|
260
277
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
|
@@ -295,6 +312,9 @@ export function ParamSlider({ paramId, label, ...rest }: ParamSliderProps) {
|
|
|
295
312
|
export interface ToggleProps {
|
|
296
313
|
on: boolean;
|
|
297
314
|
label?: string;
|
|
315
|
+
/** Side captions, hardware style: <Toggle offLabel="OFF" onLabel="ON" />. */
|
|
316
|
+
offLabel?: string;
|
|
317
|
+
onLabel?: string;
|
|
298
318
|
/** Track height; width is 1.8×. Default 22. */
|
|
299
319
|
size?: number;
|
|
300
320
|
disabled?: boolean;
|
|
@@ -308,6 +328,8 @@ export interface ToggleProps {
|
|
|
308
328
|
export function Toggle({
|
|
309
329
|
on,
|
|
310
330
|
label,
|
|
331
|
+
offLabel,
|
|
332
|
+
onLabel,
|
|
311
333
|
size = 22,
|
|
312
334
|
disabled,
|
|
313
335
|
trackColor = "#2A2F27",
|
|
@@ -322,21 +344,39 @@ export function Toggle({
|
|
|
322
344
|
|
|
323
345
|
return (
|
|
324
346
|
<View className="items-center gap-2">
|
|
325
|
-
<View
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
347
|
+
<View className="flex-row items-center gap-2">
|
|
348
|
+
{offLabel !== undefined ? (
|
|
349
|
+
<Text
|
|
350
|
+
className="text-[9] font-bold tracking-widest"
|
|
351
|
+
style={{ color: on ? "#6f6e66" : "#ECF2E8" }}
|
|
352
|
+
>
|
|
353
|
+
{offLabel}
|
|
354
|
+
</Text>
|
|
355
|
+
) : null}
|
|
330
356
|
<View
|
|
331
|
-
className=
|
|
332
|
-
style={{
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
357
|
+
className={`relative rounded-full ${disabled ? "opacity-40" : "cursor-pointer"}`}
|
|
358
|
+
style={{ width: trackWidth, height: size, backgroundColor: on ? onColor : trackColor }}
|
|
359
|
+
onClick={disabled ? undefined : () => onChange(!on)}
|
|
360
|
+
>
|
|
361
|
+
<View
|
|
362
|
+
className="absolute rounded-full"
|
|
363
|
+
style={{
|
|
364
|
+
width: thumb,
|
|
365
|
+
height: thumb,
|
|
366
|
+
top: 3,
|
|
367
|
+
left: 3 + clamp01(t) * travel,
|
|
368
|
+
backgroundColor: thumbColor,
|
|
369
|
+
}}
|
|
370
|
+
/>
|
|
371
|
+
</View>
|
|
372
|
+
{onLabel !== undefined ? (
|
|
373
|
+
<Text
|
|
374
|
+
className="text-[9] font-bold tracking-widest"
|
|
375
|
+
style={{ color: on ? "#ECF2E8" : "#6f6e66" }}
|
|
376
|
+
>
|
|
377
|
+
{onLabel}
|
|
378
|
+
</Text>
|
|
379
|
+
) : null}
|
|
340
380
|
</View>
|
|
341
381
|
{label !== undefined ? (
|
|
342
382
|
<Text className="text-faint text-[10] font-bold tracking-widest">{label}</Text>
|
package/src/feedback.tsx
CHANGED
|
@@ -14,6 +14,9 @@ export interface ProgressBarProps {
|
|
|
14
14
|
height?: number;
|
|
15
15
|
/** Renders "42%" alongside the bar. Default false. */
|
|
16
16
|
showPercent?: boolean;
|
|
17
|
+
/** Unknown duration — a segment sweeps the track and `value` is
|
|
18
|
+
ignored. Default false. */
|
|
19
|
+
indeterminate?: boolean;
|
|
17
20
|
trackColor?: string;
|
|
18
21
|
color?: string;
|
|
19
22
|
label?: string;
|
|
@@ -25,24 +28,43 @@ export function ProgressBar({
|
|
|
25
28
|
width = 200,
|
|
26
29
|
height = 8,
|
|
27
30
|
showPercent = false,
|
|
31
|
+
indeterminate = false,
|
|
28
32
|
trackColor = "#2A2F27",
|
|
29
33
|
color = "#C6F135",
|
|
30
34
|
label,
|
|
31
35
|
}: ProgressBarProps) {
|
|
32
36
|
const level = clamp01(value);
|
|
33
37
|
|
|
38
|
+
// The indeterminate sweep: a 30%-width segment marching left → right.
|
|
39
|
+
const [sweep, setSweep] = useState(0);
|
|
40
|
+
useInterval(() => setSweep((t) => (t + 0.018) % 1), indeterminate ? 16 : null);
|
|
41
|
+
const segment = width * 0.3;
|
|
42
|
+
|
|
34
43
|
const bar = (
|
|
35
44
|
<View className="flex-row items-center gap-3">
|
|
36
45
|
<View
|
|
37
|
-
className="rounded-full overflow-hidden"
|
|
46
|
+
className="rounded-full overflow-hidden relative"
|
|
38
47
|
style={{ width, height, backgroundColor: trackColor }}
|
|
39
48
|
>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
{indeterminate ? (
|
|
50
|
+
<View
|
|
51
|
+
className="rounded-full absolute"
|
|
52
|
+
style={{
|
|
53
|
+
left: sweep * (width + segment) - segment,
|
|
54
|
+
top: 0,
|
|
55
|
+
width: segment,
|
|
56
|
+
height,
|
|
57
|
+
backgroundColor: color,
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
) : (
|
|
61
|
+
<View
|
|
62
|
+
className="rounded-full"
|
|
63
|
+
style={{ width: level * width, height, backgroundColor: color }}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
44
66
|
</View>
|
|
45
|
-
{showPercent ? (
|
|
67
|
+
{showPercent && !indeterminate ? (
|
|
46
68
|
<Text className="text-faint text-[11] font-bold" style={{ width: 34 }}>
|
|
47
69
|
{`${Math.round(level * 100)}%`}
|
|
48
70
|
</Text>
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Value formatting for parameter readouts — the strings DAW users
|
|
2
|
+
// expect next to a knob. Pure functions; pair them with NumberBox's
|
|
3
|
+
// `format` prop or any Text.
|
|
4
|
+
|
|
5
|
+
const trim = (value: number, decimals: number) => String(parseFloat(value.toFixed(decimals)));
|
|
6
|
+
|
|
7
|
+
/** Linear interpolation of `value` from [inMin, inMax] to [outMin, outMax].
|
|
8
|
+
`clampOut` pins the result inside the output range. */
|
|
9
|
+
export function mapRange(
|
|
10
|
+
value: number,
|
|
11
|
+
inMin: number,
|
|
12
|
+
inMax: number,
|
|
13
|
+
outMin: number,
|
|
14
|
+
outMax: number,
|
|
15
|
+
clampOut = false,
|
|
16
|
+
): number {
|
|
17
|
+
const t = inMax === inMin ? 0 : (value - inMin) / (inMax - inMin);
|
|
18
|
+
const out = outMin + t * (outMax - outMin);
|
|
19
|
+
if (!clampOut) return out;
|
|
20
|
+
const lo = Math.min(outMin, outMax);
|
|
21
|
+
const hi = Math.max(outMin, outMax);
|
|
22
|
+
return Math.min(hi, Math.max(lo, out));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Decibels with an explicit sign: "+6.0 dB", "0.0 dB", "-12.5 dB".
|
|
26
|
+
-Infinity (a gain fader on the floor) reads "-inf dB". */
|
|
27
|
+
export function formatDb(db: number, decimals = 1): string {
|
|
28
|
+
if (db === Number.NEGATIVE_INFINITY) return "-inf dB";
|
|
29
|
+
const text = db.toFixed(decimals);
|
|
30
|
+
return `${db > 0 && !text.startsWith("+") ? "+" : ""}${text} dB`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Frequency with automatic kHz: "440 Hz", "1.2 kHz". */
|
|
34
|
+
export function formatHz(hz: number, decimals = 1): string {
|
|
35
|
+
if (Math.abs(hz) >= 1000) return `${trim(hz / 1000, Math.max(decimals, 1))} kHz`;
|
|
36
|
+
return `${trim(hz, decimals)} Hz`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Time with automatic seconds: "350 ms", "1.25 s". */
|
|
40
|
+
export function formatMs(ms: number, decimals = 0): string {
|
|
41
|
+
if (Math.abs(ms) >= 1000) return `${trim(ms / 1000, Math.max(decimals, 2))} s`;
|
|
42
|
+
return `${trim(ms, decimals)} ms`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A 0..1 value as "42%". */
|
|
46
|
+
export function formatPercent(value01: number, decimals = 0): string {
|
|
47
|
+
return `${trim(value01 * 100, decimals)}%`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Semitones with an explicit sign: "+7 st", "0 st", "-12 st". */
|
|
51
|
+
export function formatSemitones(semitones: number, decimals = 0): string {
|
|
52
|
+
const text = trim(semitones, decimals);
|
|
53
|
+
return `${semitones > 0 && !text.startsWith("+") ? "+" : ""}${text} st`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
57
|
+
|
|
58
|
+
/** A MIDI note number as scientific pitch: 60 → "C4", 69 → "A4". */
|
|
59
|
+
export function midiNoteName(note: number): string {
|
|
60
|
+
const n = Math.round(note);
|
|
61
|
+
return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
|
|
62
|
+
}
|
package/src/hostConfig.test.tsx
CHANGED
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
|
|
4
|
-
const batches: unknown[][][] = [];
|
|
5
|
-
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
6
|
-
batches.push(JSON.parse(json));
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
import { render, unmount, View, Text, TextInput } from "./index";
|
|
10
|
-
|
|
11
|
-
const allOps = () => batches.flat();
|
|
12
|
-
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
13
|
-
const dispatch = (msg: unknown) =>
|
|
14
|
-
(globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
unmount();
|
|
18
|
-
batches.length = 0;
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
describe("reconciler host config", () => {
|
|
22
|
-
test("initial mount emits create/setProps/append ops ending at the root", () => {
|
|
23
|
-
render(
|
|
24
|
-
<View className="p-4">
|
|
25
|
-
<Text>hi</Text>
|
|
26
|
-
</View>,
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
const ops = allOps();
|
|
30
|
-
const creates = opsNamed("create");
|
|
31
|
-
|
|
32
|
-
// view + text + rawtext
|
|
33
|
-
expect(creates.length).toBe(3);
|
|
34
|
-
const viewId = (creates.find((op: any) => op[2] === "view") as any)[1];
|
|
35
|
-
const textId = (creates.find((op: any) => op[2] === "text") as any)[1];
|
|
36
|
-
const rawId = (creates.find((op: any) => op[2] === "rawtext") as any)[1];
|
|
37
|
-
|
|
38
|
-
const viewProps: any = opsNamed("setProps").find((op: any) => op[1] === viewId);
|
|
39
|
-
expect(viewProps[2].style.padding).toBe(16);
|
|
40
|
-
|
|
41
|
-
expect(opsNamed("setText")).toContainEqual(["setText", rawId, "hi"]);
|
|
42
|
-
expect(opsNamed("appendChild")).toContainEqual(["appendChild", textId, rawId]);
|
|
43
|
-
expect(opsNamed("appendChild")).toContainEqual(["appendChild", viewId, textId]);
|
|
44
|
-
expect(opsNamed("appendChild")).toContainEqual(["appendChild", 0, viewId]);
|
|
45
|
-
|
|
46
|
-
// root attach comes after the subtree is assembled
|
|
47
|
-
const opNames = ops.map((op: any) => op.join(":"));
|
|
48
|
-
expect(opNames.indexOf(`appendChild:0:${viewId}`)).toBeGreaterThan(
|
|
49
|
-
opNames.indexOf(`appendChild:${viewId}:${textId}`),
|
|
50
|
-
);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("click handlers register as listeners and receive dispatches", () => {
|
|
54
|
-
let clicks = 0;
|
|
55
|
-
|
|
56
|
-
render(<View onClick={() => clicks++} className="w-4" />);
|
|
57
|
-
|
|
58
|
-
const create: any = opsNamed("create")[0];
|
|
59
|
-
const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
|
|
60
|
-
expect(props[2].listeners).toEqual(["click"]);
|
|
61
|
-
|
|
62
|
-
dispatch({ kind: "event", nodeId: create[1], type: "click" });
|
|
63
|
-
expect(clicks).toBe(1);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("state updates emit new setProps and setText", () => {
|
|
67
|
-
function Counter() {
|
|
68
|
-
const [count, setCount] = useState(0);
|
|
69
|
-
return (
|
|
70
|
-
<View onClick={() => setCount((c) => c + 1)}>
|
|
71
|
-
<Text>{`count:${count}`}</Text>
|
|
72
|
-
</View>
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
render(<Counter />);
|
|
77
|
-
|
|
78
|
-
const viewId = (opsNamed("create").find((op: any) => op[2] === "view") as any)[1];
|
|
79
|
-
batches.length = 0;
|
|
80
|
-
|
|
81
|
-
dispatch({ kind: "event", nodeId: viewId, type: "click" });
|
|
82
|
-
|
|
83
|
-
expect(opsNamed("setText").some((op: any) => op[2] === "count:1")).toBe(true);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("TextInput onChange receives the plain string value", () => {
|
|
87
|
-
const seen: string[] = [];
|
|
88
|
-
|
|
89
|
-
render(<TextInput onChange={(v) => seen.push(v)} />);
|
|
90
|
-
|
|
91
|
-
const inputId = (opsNamed("create").find((op: any) => op[2] === "textinput") as any)[1];
|
|
92
|
-
dispatch({ kind: "event", nodeId: inputId, type: "change", payload: { value: "abc" } });
|
|
93
|
-
|
|
94
|
-
expect(seen).toEqual(["abc"]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("conditional removal emits removeChild", () => {
|
|
98
|
-
function Toggle({ on }: { on: boolean }) {
|
|
99
|
-
return <View>{on ? <View className="w-2" /> : null}</View>;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
render(<Toggle on={true} />);
|
|
103
|
-
// React creates instances bottom-up: the inner view is created first.
|
|
104
|
-
const childId = (opsNamed("create")[0] as any)[1];
|
|
105
|
-
batches.length = 0;
|
|
106
|
-
|
|
107
|
-
render(<Toggle on={false} />);
|
|
108
|
-
expect(opsNamed("removeChild").some((op: any) => op[2] === childId)).toBe(true);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("unmount clears the container", () => {
|
|
112
|
-
render(<View />);
|
|
113
|
-
batches.length = 0;
|
|
114
|
-
unmount();
|
|
115
|
-
const ops = allOps();
|
|
116
|
-
expect(ops.some((op: any) => op[0] === "removeChild" || op[0] === "clearContainer")).toBe(
|
|
117
|
-
true,
|
|
118
|
-
);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
const batches: unknown[][][] = [];
|
|
5
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
6
|
+
batches.push(JSON.parse(json));
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
import { render, unmount, View, Text, TextInput } from "./index";
|
|
10
|
+
|
|
11
|
+
const allOps = () => batches.flat();
|
|
12
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
13
|
+
const dispatch = (msg: unknown) =>
|
|
14
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
unmount();
|
|
18
|
+
batches.length = 0;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("reconciler host config", () => {
|
|
22
|
+
test("initial mount emits create/setProps/append ops ending at the root", () => {
|
|
23
|
+
render(
|
|
24
|
+
<View className="p-4">
|
|
25
|
+
<Text>hi</Text>
|
|
26
|
+
</View>,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const ops = allOps();
|
|
30
|
+
const creates = opsNamed("create");
|
|
31
|
+
|
|
32
|
+
// view + text + rawtext
|
|
33
|
+
expect(creates.length).toBe(3);
|
|
34
|
+
const viewId = (creates.find((op: any) => op[2] === "view") as any)[1];
|
|
35
|
+
const textId = (creates.find((op: any) => op[2] === "text") as any)[1];
|
|
36
|
+
const rawId = (creates.find((op: any) => op[2] === "rawtext") as any)[1];
|
|
37
|
+
|
|
38
|
+
const viewProps: any = opsNamed("setProps").find((op: any) => op[1] === viewId);
|
|
39
|
+
expect(viewProps[2].style.padding).toBe(16);
|
|
40
|
+
|
|
41
|
+
expect(opsNamed("setText")).toContainEqual(["setText", rawId, "hi"]);
|
|
42
|
+
expect(opsNamed("appendChild")).toContainEqual(["appendChild", textId, rawId]);
|
|
43
|
+
expect(opsNamed("appendChild")).toContainEqual(["appendChild", viewId, textId]);
|
|
44
|
+
expect(opsNamed("appendChild")).toContainEqual(["appendChild", 0, viewId]);
|
|
45
|
+
|
|
46
|
+
// root attach comes after the subtree is assembled
|
|
47
|
+
const opNames = ops.map((op: any) => op.join(":"));
|
|
48
|
+
expect(opNames.indexOf(`appendChild:0:${viewId}`)).toBeGreaterThan(
|
|
49
|
+
opNames.indexOf(`appendChild:${viewId}:${textId}`),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("click handlers register as listeners and receive dispatches", () => {
|
|
54
|
+
let clicks = 0;
|
|
55
|
+
|
|
56
|
+
render(<View onClick={() => clicks++} className="w-4" />);
|
|
57
|
+
|
|
58
|
+
const create: any = opsNamed("create")[0];
|
|
59
|
+
const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
|
|
60
|
+
expect(props[2].listeners).toEqual(["click"]);
|
|
61
|
+
|
|
62
|
+
dispatch({ kind: "event", nodeId: create[1], type: "click" });
|
|
63
|
+
expect(clicks).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("state updates emit new setProps and setText", () => {
|
|
67
|
+
function Counter() {
|
|
68
|
+
const [count, setCount] = useState(0);
|
|
69
|
+
return (
|
|
70
|
+
<View onClick={() => setCount((c) => c + 1)}>
|
|
71
|
+
<Text>{`count:${count}`}</Text>
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render(<Counter />);
|
|
77
|
+
|
|
78
|
+
const viewId = (opsNamed("create").find((op: any) => op[2] === "view") as any)[1];
|
|
79
|
+
batches.length = 0;
|
|
80
|
+
|
|
81
|
+
dispatch({ kind: "event", nodeId: viewId, type: "click" });
|
|
82
|
+
|
|
83
|
+
expect(opsNamed("setText").some((op: any) => op[2] === "count:1")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("TextInput onChange receives the plain string value", () => {
|
|
87
|
+
const seen: string[] = [];
|
|
88
|
+
|
|
89
|
+
render(<TextInput onChange={(v) => seen.push(v)} />);
|
|
90
|
+
|
|
91
|
+
const inputId = (opsNamed("create").find((op: any) => op[2] === "textinput") as any)[1];
|
|
92
|
+
dispatch({ kind: "event", nodeId: inputId, type: "change", payload: { value: "abc" } });
|
|
93
|
+
|
|
94
|
+
expect(seen).toEqual(["abc"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("conditional removal emits removeChild", () => {
|
|
98
|
+
function Toggle({ on }: { on: boolean }) {
|
|
99
|
+
return <View>{on ? <View className="w-2" /> : null}</View>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
render(<Toggle on={true} />);
|
|
103
|
+
// React creates instances bottom-up: the inner view is created first.
|
|
104
|
+
const childId = (opsNamed("create")[0] as any)[1];
|
|
105
|
+
batches.length = 0;
|
|
106
|
+
|
|
107
|
+
render(<Toggle on={false} />);
|
|
108
|
+
expect(opsNamed("removeChild").some((op: any) => op[2] === childId)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("unmount clears the container", () => {
|
|
112
|
+
render(<View />);
|
|
113
|
+
batches.length = 0;
|
|
114
|
+
unmount();
|
|
115
|
+
const ops = allOps();
|
|
116
|
+
expect(ops.some((op: any) => op[0] === "removeChild" || op[0] === "clearContainer")).toBe(
|
|
117
|
+
true,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
});
|