@praxisjs/runtime 0.2.2 → 0.2.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/CHANGELOG.md +18 -0
- package/dist/__tests__/children.test.d.ts +2 -0
- package/dist/__tests__/children.test.d.ts.map +1 -0
- package/dist/__tests__/children.test.js +87 -0
- package/dist/__tests__/children.test.js.map +1 -0
- package/dist/__tests__/component.test.d.ts +2 -0
- package/dist/__tests__/component.test.d.ts.map +1 -0
- package/dist/__tests__/component.test.js +134 -0
- package/dist/__tests__/component.test.js.map +1 -0
- package/dist/__tests__/context.test.d.ts +2 -0
- package/dist/__tests__/context.test.d.ts.map +1 -0
- package/dist/__tests__/context.test.js +43 -0
- package/dist/__tests__/context.test.js.map +1 -0
- package/dist/__tests__/dom.test.d.ts +2 -0
- package/dist/__tests__/dom.test.d.ts.map +1 -0
- package/dist/__tests__/dom.test.js +174 -0
- package/dist/__tests__/dom.test.js.map +1 -0
- package/dist/__tests__/render.test.d.ts +2 -0
- package/dist/__tests__/render.test.d.ts.map +1 -0
- package/dist/__tests__/render.test.js +73 -0
- package/dist/__tests__/render.test.js.map +1 -0
- package/dist/__tests__/scope.test.d.ts +2 -0
- package/dist/__tests__/scope.test.d.ts.map +1 -0
- package/dist/__tests__/scope.test.js +56 -0
- package/dist/__tests__/scope.test.js.map +1 -0
- package/package.json +4 -4
- package/src/__tests__/children.test.ts +99 -0
- package/src/__tests__/component.test.ts +148 -0
- package/src/__tests__/context.test.ts +53 -0
- package/src/__tests__/dom.test.ts +202 -0
- package/src/__tests__/render.test.ts +89 -0
- package/src/__tests__/scope.test.ts +62 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { Scope } from "../scope";
|
|
3
|
+
describe("Scope", () => {
|
|
4
|
+
it("add() registers a cleanup fn that runs on dispose()", () => {
|
|
5
|
+
const scope = new Scope();
|
|
6
|
+
const cleanup = vi.fn();
|
|
7
|
+
scope.add(cleanup);
|
|
8
|
+
scope.dispose();
|
|
9
|
+
expect(cleanup).toHaveBeenCalledOnce();
|
|
10
|
+
});
|
|
11
|
+
it("dispose() runs all cleanups in order", () => {
|
|
12
|
+
const scope = new Scope();
|
|
13
|
+
const order = [];
|
|
14
|
+
scope.add(() => order.push(1));
|
|
15
|
+
scope.add(() => order.push(2));
|
|
16
|
+
scope.add(() => order.push(3));
|
|
17
|
+
scope.dispose();
|
|
18
|
+
expect(order).toEqual([1, 2, 3]);
|
|
19
|
+
});
|
|
20
|
+
it("dispose() clears cleanups — second dispose() is a no-op", () => {
|
|
21
|
+
const scope = new Scope();
|
|
22
|
+
const fn = vi.fn();
|
|
23
|
+
scope.add(fn);
|
|
24
|
+
scope.dispose();
|
|
25
|
+
scope.dispose();
|
|
26
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
27
|
+
});
|
|
28
|
+
it("effect() registers a reactive effect and its cleanup", () => {
|
|
29
|
+
const scope = new Scope();
|
|
30
|
+
const ran = [];
|
|
31
|
+
scope.effect(() => {
|
|
32
|
+
ran.push(1);
|
|
33
|
+
});
|
|
34
|
+
expect(ran).toHaveLength(1); // runs immediately
|
|
35
|
+
scope.dispose();
|
|
36
|
+
});
|
|
37
|
+
it("fork() creates a child scope that disposes when parent disposes", () => {
|
|
38
|
+
const parent = new Scope();
|
|
39
|
+
const child = parent.fork();
|
|
40
|
+
const childCleanup = vi.fn();
|
|
41
|
+
child.add(childCleanup);
|
|
42
|
+
parent.dispose();
|
|
43
|
+
expect(childCleanup).toHaveBeenCalledOnce();
|
|
44
|
+
});
|
|
45
|
+
it("fork() child can be disposed independently before parent", () => {
|
|
46
|
+
const parent = new Scope();
|
|
47
|
+
const child = parent.fork();
|
|
48
|
+
const childFn = vi.fn();
|
|
49
|
+
child.add(childFn);
|
|
50
|
+
child.dispose();
|
|
51
|
+
expect(childFn).toHaveBeenCalledOnce();
|
|
52
|
+
// parent dispose should not throw even though child is already disposed
|
|
53
|
+
expect(() => { parent.dispose(); }).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
//# sourceMappingURL=scope.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope.test.js","sourceRoot":"","sources":["../../src/__tests__/scope.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@praxisjs/runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"typescript": "^5.9.3"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@praxisjs/core": "0.4.
|
|
18
|
-
"@praxisjs/
|
|
19
|
-
"@praxisjs/
|
|
17
|
+
"@praxisjs/core": "0.4.2",
|
|
18
|
+
"@praxisjs/shared": "0.2.0",
|
|
19
|
+
"@praxisjs/decorators": "0.4.3"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsc",
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { signal } from "@praxisjs/core/internal";
|
|
5
|
+
|
|
6
|
+
import { mountChildren } from "../children";
|
|
7
|
+
import { Scope } from "../scope";
|
|
8
|
+
|
|
9
|
+
function container() {
|
|
10
|
+
return document.createElement("div");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("mountChildren", () => {
|
|
14
|
+
it("does nothing for null, undefined, or false", () => {
|
|
15
|
+
const el = container();
|
|
16
|
+
const scope = new Scope();
|
|
17
|
+
mountChildren(el, null, scope);
|
|
18
|
+
mountChildren(el, undefined, scope);
|
|
19
|
+
mountChildren(el, false, scope);
|
|
20
|
+
expect(el.childNodes.length).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("appends a text node for a string", () => {
|
|
24
|
+
const el = container();
|
|
25
|
+
const scope = new Scope();
|
|
26
|
+
mountChildren(el, "hello", scope);
|
|
27
|
+
expect(el.textContent).toBe("hello");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("appends a text node for a number", () => {
|
|
31
|
+
const el = container();
|
|
32
|
+
const scope = new Scope();
|
|
33
|
+
mountChildren(el, 42, scope);
|
|
34
|
+
expect(el.textContent).toBe("42");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("appends a DOM node directly", () => {
|
|
38
|
+
const el = container();
|
|
39
|
+
const scope = new Scope();
|
|
40
|
+
const span = document.createElement("span");
|
|
41
|
+
mountChildren(el, span, scope);
|
|
42
|
+
expect(el.firstChild).toBe(span);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("recursively mounts arrays", () => {
|
|
46
|
+
const el = container();
|
|
47
|
+
const scope = new Scope();
|
|
48
|
+
mountChildren(el, ["a", "b", "c"], scope);
|
|
49
|
+
expect(el.textContent).toBe("abc");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("mounts a reactive function — updates when signal changes", () => {
|
|
53
|
+
const el = container();
|
|
54
|
+
const scope = new Scope();
|
|
55
|
+
const s = signal("first");
|
|
56
|
+
mountChildren(el, () => s(), scope);
|
|
57
|
+
expect(el.textContent).toBe("first");
|
|
58
|
+
s.set("second");
|
|
59
|
+
expect(el.textContent).toBe("second");
|
|
60
|
+
scope.dispose();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("reactive children clean up old nodes on update", () => {
|
|
64
|
+
const el = container();
|
|
65
|
+
const scope = new Scope();
|
|
66
|
+
const s = signal<string | null>("visible");
|
|
67
|
+
mountChildren(el, () => s(), scope);
|
|
68
|
+
expect(el.textContent).toBe("visible");
|
|
69
|
+
s.set(null);
|
|
70
|
+
expect(el.textContent).toBe("");
|
|
71
|
+
scope.dispose();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("reactive function returning an array of nodes renders all of them", () => {
|
|
75
|
+
const el = container();
|
|
76
|
+
const scope = new Scope();
|
|
77
|
+
mountChildren(el, () => ["x", "y", "z"], scope);
|
|
78
|
+
expect(el.textContent).toBe("xyz");
|
|
79
|
+
scope.dispose();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reactive function returning a Node renders it", () => {
|
|
83
|
+
const el = container();
|
|
84
|
+
const scope = new Scope();
|
|
85
|
+
const node = document.createElement("em");
|
|
86
|
+
node.textContent = "em";
|
|
87
|
+
mountChildren(el, () => node, scope);
|
|
88
|
+
expect(el.textContent).toBe("em");
|
|
89
|
+
scope.dispose();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reactive function returning a number renders it", () => {
|
|
93
|
+
const el = container();
|
|
94
|
+
const scope = new Scope();
|
|
95
|
+
mountChildren(el, () => 42, scope);
|
|
96
|
+
expect(el.textContent).toBe("42");
|
|
97
|
+
scope.dispose();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { mountComponent } from "../component";
|
|
5
|
+
import { Scope } from "../scope";
|
|
6
|
+
import { StatefulComponent } from "@praxisjs/core";
|
|
7
|
+
|
|
8
|
+
class SimpleComp extends StatefulComponent {
|
|
9
|
+
static __isComponent = true as const;
|
|
10
|
+
static __isStateless = false;
|
|
11
|
+
render() {
|
|
12
|
+
return document.createTextNode("hello");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class NullComp extends StatefulComponent {
|
|
17
|
+
static __isComponent = true as const;
|
|
18
|
+
static __isStateless = false;
|
|
19
|
+
render() { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class MultiComp extends StatefulComponent {
|
|
23
|
+
static __isComponent = true as const;
|
|
24
|
+
static __isStateless = false;
|
|
25
|
+
render() {
|
|
26
|
+
return [
|
|
27
|
+
document.createTextNode("a"),
|
|
28
|
+
document.createTextNode("b"),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class ErrorComp extends StatefulComponent {
|
|
34
|
+
static __isComponent = true as const;
|
|
35
|
+
static __isStateless = false;
|
|
36
|
+
onError(_err: Error) {}
|
|
37
|
+
render(): never {
|
|
38
|
+
throw new Error("render error");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class LifecycleComp extends StatefulComponent {
|
|
43
|
+
static __isComponent = true as const;
|
|
44
|
+
static __isStateless = false;
|
|
45
|
+
onBeforeMount() {}
|
|
46
|
+
onMount() {}
|
|
47
|
+
onUnmount() {}
|
|
48
|
+
render() { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("mountComponent", () => {
|
|
52
|
+
it("returns an array of nodes", () => {
|
|
53
|
+
const scope = new Scope();
|
|
54
|
+
const nodes = mountComponent(SimpleComp, {}, scope);
|
|
55
|
+
expect(Array.isArray(nodes)).toBe(true);
|
|
56
|
+
expect(nodes.length).toBeGreaterThan(0);
|
|
57
|
+
scope.dispose();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("wraps output with start/end comment anchors", () => {
|
|
61
|
+
const scope = new Scope();
|
|
62
|
+
const nodes = mountComponent(SimpleComp, {}, scope);
|
|
63
|
+
expect(nodes[0].nodeType).toBe(Node.COMMENT_NODE);
|
|
64
|
+
expect(nodes[nodes.length - 1].nodeType).toBe(Node.COMMENT_NODE);
|
|
65
|
+
scope.dispose();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("mounts the rendered content between comments", () => {
|
|
69
|
+
const scope = new Scope();
|
|
70
|
+
const container = document.createElement("div");
|
|
71
|
+
const nodes = mountComponent(SimpleComp, {}, scope);
|
|
72
|
+
nodes.forEach((n) => container.appendChild(n));
|
|
73
|
+
expect(container.textContent).toContain("hello");
|
|
74
|
+
scope.dispose();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("calls onBeforeMount before render", () => {
|
|
78
|
+
const scope = new Scope();
|
|
79
|
+
const order: string[] = [];
|
|
80
|
+
class OrderComp extends StatefulComponent {
|
|
81
|
+
static __isComponent = true as const;
|
|
82
|
+
static __isStateless = false;
|
|
83
|
+
onBeforeMount() { order.push("before"); }
|
|
84
|
+
render() {
|
|
85
|
+
order.push("render");
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
mountComponent(OrderComp, {}, scope);
|
|
90
|
+
expect(order).toEqual(["before", "render"]);
|
|
91
|
+
scope.dispose();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("calls onMount asynchronously after mount", async () => {
|
|
95
|
+
const scope = new Scope();
|
|
96
|
+
const onMount = vi.spyOn(LifecycleComp.prototype, "onMount");
|
|
97
|
+
mountComponent(LifecycleComp, {}, scope);
|
|
98
|
+
expect(onMount).not.toHaveBeenCalled(); // not called synchronously
|
|
99
|
+
await Promise.resolve(); // flush microtask
|
|
100
|
+
expect(onMount).toHaveBeenCalled();
|
|
101
|
+
onMount.mockRestore();
|
|
102
|
+
scope.dispose();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("calls onUnmount when scope is disposed", async () => {
|
|
106
|
+
const scope = new Scope();
|
|
107
|
+
const onUnmount = vi.spyOn(LifecycleComp.prototype, "onUnmount");
|
|
108
|
+
mountComponent(LifecycleComp, {}, scope);
|
|
109
|
+
await Promise.resolve();
|
|
110
|
+
scope.dispose();
|
|
111
|
+
expect(onUnmount).toHaveBeenCalled();
|
|
112
|
+
onUnmount.mockRestore();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("calls onError when render throws", () => {
|
|
116
|
+
const scope = new Scope();
|
|
117
|
+
const onError = vi.spyOn(ErrorComp.prototype, "onError");
|
|
118
|
+
mountComponent(ErrorComp, {}, scope);
|
|
119
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
120
|
+
onError.mockRestore();
|
|
121
|
+
scope.dispose();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("passes props to the component instance", () => {
|
|
125
|
+
let receivedProp: unknown;
|
|
126
|
+
class PropsComp extends StatefulComponent {
|
|
127
|
+
static __isComponent = true as const;
|
|
128
|
+
static __isStateless = false;
|
|
129
|
+
render() {
|
|
130
|
+
receivedProp = this.props.msg;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const scope = new Scope();
|
|
135
|
+
mountComponent(PropsComp, { msg: "hello" }, scope);
|
|
136
|
+
expect(receivedProp).toBe("hello");
|
|
137
|
+
scope.dispose();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("mounts array of children", () => {
|
|
141
|
+
const scope = new Scope();
|
|
142
|
+
const container = document.createElement("div");
|
|
143
|
+
const nodes = mountComponent(MultiComp, {}, scope);
|
|
144
|
+
nodes.forEach((n) => container.appendChild(n));
|
|
145
|
+
expect(container.textContent).toBe("ab");
|
|
146
|
+
scope.dispose();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { runInScope, getCurrentScope } from "../context";
|
|
4
|
+
import { Scope } from "../scope";
|
|
5
|
+
|
|
6
|
+
describe("runInScope / getCurrentScope", () => {
|
|
7
|
+
it("getCurrentScope throws when called outside of a render context", () => {
|
|
8
|
+
expect(() => getCurrentScope()).toThrow("[PraxisJS]");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("runInScope makes the scope available via getCurrentScope", () => {
|
|
12
|
+
const scope = new Scope();
|
|
13
|
+
let captured: Scope | null = null;
|
|
14
|
+
runInScope(scope, () => {
|
|
15
|
+
captured = getCurrentScope();
|
|
16
|
+
});
|
|
17
|
+
expect(captured).toBe(scope);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("restores previous scope after runInScope exits", () => {
|
|
21
|
+
const outer = new Scope();
|
|
22
|
+
const inner = new Scope();
|
|
23
|
+
let innerCaptured: Scope | null = null;
|
|
24
|
+
let outerAfter: Scope | null = null;
|
|
25
|
+
|
|
26
|
+
runInScope(outer, () => {
|
|
27
|
+
runInScope(inner, () => {
|
|
28
|
+
innerCaptured = getCurrentScope();
|
|
29
|
+
});
|
|
30
|
+
outerAfter = getCurrentScope();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(innerCaptured).toBe(inner);
|
|
34
|
+
expect(outerAfter).toBe(outer);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("restores null scope even when the callback throws", () => {
|
|
38
|
+
const scope = new Scope();
|
|
39
|
+
expect(() =>
|
|
40
|
+
runInScope(scope, () => {
|
|
41
|
+
throw new Error("boom");
|
|
42
|
+
}),
|
|
43
|
+
).toThrow("boom");
|
|
44
|
+
|
|
45
|
+
expect(() => getCurrentScope()).toThrow("[PraxisJS]");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns the callback's return value", () => {
|
|
49
|
+
const scope = new Scope();
|
|
50
|
+
const result = runInScope(scope, () => 42);
|
|
51
|
+
expect(result).toBe(42);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { signal } from "@praxisjs/core/internal";
|
|
5
|
+
|
|
6
|
+
import { createElement } from "../dom/create";
|
|
7
|
+
import { addEvent } from "../dom/events";
|
|
8
|
+
import { applyProp } from "../dom/props";
|
|
9
|
+
import { Scope } from "../scope";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
// ── createElement ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe("createElement", () => {
|
|
15
|
+
it("creates an HTMLElement for regular tags", () => {
|
|
16
|
+
const el = createElement("div");
|
|
17
|
+
expect(el).toBeInstanceOf(HTMLDivElement);
|
|
18
|
+
expect(el.tagName.toLowerCase()).toBe("div");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("creates an SVGElement for svg tags", () => {
|
|
22
|
+
const el = createElement("svg");
|
|
23
|
+
expect(el).toBeInstanceOf(SVGElement);
|
|
24
|
+
expect(el.tagName.toLowerCase()).toBe("svg");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates nested svg elements with the SVG namespace", () => {
|
|
28
|
+
const path = createElement("path");
|
|
29
|
+
expect(path.namespaceURI).toBe("http://www.w3.org/2000/svg");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── applyProp ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("applyProp", () => {
|
|
36
|
+
it("sets class attribute", () => {
|
|
37
|
+
const el = document.createElement("div");
|
|
38
|
+
const scope = new Scope();
|
|
39
|
+
applyProp(el, "class", "foo bar", scope);
|
|
40
|
+
expect(el.getAttribute("class")).toBe("foo bar");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("sets className as class attribute", () => {
|
|
44
|
+
const el = document.createElement("div");
|
|
45
|
+
const scope = new Scope();
|
|
46
|
+
applyProp(el, "className", "my-class", scope);
|
|
47
|
+
expect(el.getAttribute("class")).toBe("my-class");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("removes class when value is null", () => {
|
|
51
|
+
const el = document.createElement("div");
|
|
52
|
+
el.setAttribute("class", "old");
|
|
53
|
+
const scope = new Scope();
|
|
54
|
+
applyProp(el, "class", null, scope);
|
|
55
|
+
expect(el.hasAttribute("class")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("sets style as string", () => {
|
|
59
|
+
const el = document.createElement("div");
|
|
60
|
+
const scope = new Scope();
|
|
61
|
+
applyProp(el, "style", "color: red;", scope);
|
|
62
|
+
expect(el.getAttribute("style")).toBe("color: red;");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("sets style as object", () => {
|
|
66
|
+
const el = document.createElement("div") as HTMLElement;
|
|
67
|
+
const scope = new Scope();
|
|
68
|
+
applyProp(el, "style", { color: "blue" }, scope);
|
|
69
|
+
expect(el.style.color).toBe("blue");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("sets boolean true as empty attribute", () => {
|
|
73
|
+
const el = document.createElement("input");
|
|
74
|
+
const scope = new Scope();
|
|
75
|
+
applyProp(el, "disabled", true, scope);
|
|
76
|
+
expect(el.hasAttribute("disabled")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("removes attribute when value is false", () => {
|
|
80
|
+
const el = document.createElement("input");
|
|
81
|
+
el.setAttribute("disabled", "");
|
|
82
|
+
const scope = new Scope();
|
|
83
|
+
applyProp(el, "disabled", false, scope);
|
|
84
|
+
expect(el.hasAttribute("disabled")).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("sets value prop directly on element", () => {
|
|
88
|
+
const el = document.createElement("input");
|
|
89
|
+
const scope = new Scope();
|
|
90
|
+
applyProp(el, "value", "hello", scope);
|
|
91
|
+
expect(el.value).toBe("hello");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("calls ref callback with the element", () => {
|
|
95
|
+
const el = document.createElement("span");
|
|
96
|
+
const scope = new Scope();
|
|
97
|
+
const ref = vi.fn();
|
|
98
|
+
applyProp(el, "ref", ref, scope);
|
|
99
|
+
expect(ref).toHaveBeenCalledWith(el);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("skips 'children' and 'key' props", () => {
|
|
103
|
+
const el = document.createElement("div");
|
|
104
|
+
const scope = new Scope();
|
|
105
|
+
applyProp(el, "children", "should be ignored", scope);
|
|
106
|
+
applyProp(el, "key", "k1", scope);
|
|
107
|
+
expect(el.childNodes.length).toBe(0);
|
|
108
|
+
expect(el.hasAttribute("key")).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("attaches event listener for onClick", () => {
|
|
112
|
+
const el = document.createElement("button");
|
|
113
|
+
const scope = new Scope();
|
|
114
|
+
const handler = vi.fn();
|
|
115
|
+
applyProp(el, "onClick", handler, scope);
|
|
116
|
+
el.dispatchEvent(new MouseEvent("click"));
|
|
117
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("removes event listener on scope dispose", () => {
|
|
121
|
+
const el = document.createElement("button");
|
|
122
|
+
const scope = new Scope();
|
|
123
|
+
const handler = vi.fn();
|
|
124
|
+
applyProp(el, "onClick", handler, scope);
|
|
125
|
+
scope.dispose();
|
|
126
|
+
el.dispatchEvent(new MouseEvent("click"));
|
|
127
|
+
expect(handler).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("tracks reactive prop — updates when signal changes", () => {
|
|
131
|
+
const el = document.createElement("div");
|
|
132
|
+
const scope = new Scope();
|
|
133
|
+
const cls = signal("initial");
|
|
134
|
+
applyProp(el, "class", () => cls(), scope);
|
|
135
|
+
expect(el.getAttribute("class")).toBe("initial");
|
|
136
|
+
cls.set("updated");
|
|
137
|
+
expect(el.getAttribute("class")).toBe("updated");
|
|
138
|
+
scope.dispose();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("normalizes htmlFor to for attribute", () => {
|
|
142
|
+
const label = document.createElement("label");
|
|
143
|
+
const scope = new Scope();
|
|
144
|
+
applyProp(label, "htmlFor", "my-input", scope);
|
|
145
|
+
expect(label.getAttribute("for")).toBe("my-input");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("removes style attribute when value is null", () => {
|
|
149
|
+
const el = document.createElement("div");
|
|
150
|
+
el.setAttribute("style", "color: red;");
|
|
151
|
+
const scope = new Scope();
|
|
152
|
+
applyProp(el, "style", null, scope);
|
|
153
|
+
expect(el.hasAttribute("style")).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("removes style attribute when value is undefined", () => {
|
|
157
|
+
const el = document.createElement("div");
|
|
158
|
+
el.setAttribute("style", "color: red;");
|
|
159
|
+
const scope = new Scope();
|
|
160
|
+
applyProp(el, "style", undefined, scope);
|
|
161
|
+
expect(el.hasAttribute("style")).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("removes attribute when value is null for generic attr", () => {
|
|
165
|
+
const el = document.createElement("div");
|
|
166
|
+
el.setAttribute("data-x", "1");
|
|
167
|
+
const scope = new Scope();
|
|
168
|
+
applyProp(el, "data-x", null, scope);
|
|
169
|
+
expect(el.hasAttribute("data-x")).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("removes attribute when value is undefined for generic attr", () => {
|
|
173
|
+
const el = document.createElement("div");
|
|
174
|
+
el.setAttribute("data-y", "1");
|
|
175
|
+
const scope = new Scope();
|
|
176
|
+
applyProp(el, "data-y", undefined, scope);
|
|
177
|
+
expect(el.hasAttribute("data-y")).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── addEvent ─────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe("addEvent", () => {
|
|
184
|
+
it("adds event listener that fires on dispatch", () => {
|
|
185
|
+
const el = document.createElement("button");
|
|
186
|
+
const scope = new Scope();
|
|
187
|
+
const fn = vi.fn();
|
|
188
|
+
addEvent(el, "click", fn, scope);
|
|
189
|
+
el.dispatchEvent(new MouseEvent("click"));
|
|
190
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("removes listener when scope is disposed", () => {
|
|
194
|
+
const el = document.createElement("button");
|
|
195
|
+
const scope = new Scope();
|
|
196
|
+
const fn = vi.fn();
|
|
197
|
+
addEvent(el, "click", fn, scope);
|
|
198
|
+
scope.dispose();
|
|
199
|
+
el.dispatchEvent(new MouseEvent("click"));
|
|
200
|
+
expect(fn).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { signal } from "@praxisjs/core/internal";
|
|
5
|
+
|
|
6
|
+
import { render, mountElement } from "../index";
|
|
7
|
+
import { Scope } from "../scope";
|
|
8
|
+
|
|
9
|
+
// ── mountElement ─────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("mountElement", () => {
|
|
12
|
+
it("creates an element with the given tag", () => {
|
|
13
|
+
const scope = new Scope();
|
|
14
|
+
const el = mountElement("section", {}, scope);
|
|
15
|
+
expect(el.tagName.toLowerCase()).toBe("section");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("applies static props", () => {
|
|
19
|
+
const scope = new Scope();
|
|
20
|
+
const el = mountElement("p", { id: "para", class: "text" }, scope) as HTMLElement;
|
|
21
|
+
expect(el.id).toBe("para");
|
|
22
|
+
expect(el.getAttribute("class")).toBe("text");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("mounts string children", () => {
|
|
26
|
+
const scope = new Scope();
|
|
27
|
+
const el = mountElement("p", { children: "hello" }, scope);
|
|
28
|
+
expect(el.textContent).toBe("hello");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("mounts reactive prop — updates when signal changes", () => {
|
|
32
|
+
const scope = new Scope();
|
|
33
|
+
const cls = signal("a");
|
|
34
|
+
const el = mountElement("div", { class: () => cls() }, scope);
|
|
35
|
+
expect(el.getAttribute("class")).toBe("a");
|
|
36
|
+
cls.set("b");
|
|
37
|
+
expect(el.getAttribute("class")).toBe("b");
|
|
38
|
+
scope.dispose();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── render ───────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
describe("render", () => {
|
|
45
|
+
it("mounts content into container", () => {
|
|
46
|
+
const container = document.createElement("div");
|
|
47
|
+
document.body.appendChild(container);
|
|
48
|
+
|
|
49
|
+
const scope = new Scope();
|
|
50
|
+
render(() => {
|
|
51
|
+
const el = mountElement("h1", { children: "Hello" }, scope);
|
|
52
|
+
return el;
|
|
53
|
+
}, container);
|
|
54
|
+
|
|
55
|
+
expect(container.textContent).toBe("Hello");
|
|
56
|
+
document.body.removeChild(container);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("clears container on unmount", () => {
|
|
60
|
+
const container = document.createElement("div");
|
|
61
|
+
document.body.appendChild(container);
|
|
62
|
+
|
|
63
|
+
const unmount = render(() => {
|
|
64
|
+
const scope = new Scope();
|
|
65
|
+
return mountElement("p", { children: "bye" }, scope);
|
|
66
|
+
}, container);
|
|
67
|
+
|
|
68
|
+
expect(container.textContent).toBe("bye");
|
|
69
|
+
unmount();
|
|
70
|
+
expect(container.innerHTML).toBe("");
|
|
71
|
+
document.body.removeChild(container);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("reactive content re-renders on signal change", () => {
|
|
75
|
+
const container = document.createElement("div");
|
|
76
|
+
document.body.appendChild(container);
|
|
77
|
+
const text = signal("initial");
|
|
78
|
+
|
|
79
|
+
render(() => {
|
|
80
|
+
const scope = new Scope();
|
|
81
|
+
return mountElement("span", { children: () => text() }, scope);
|
|
82
|
+
}, container);
|
|
83
|
+
|
|
84
|
+
expect(container.textContent).toBe("initial");
|
|
85
|
+
text.set("updated");
|
|
86
|
+
expect(container.textContent).toBe("updated");
|
|
87
|
+
document.body.removeChild(container);
|
|
88
|
+
});
|
|
89
|
+
});
|