@granularjs/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +576 -0
- package/dist/granular.min.js +2 -0
- package/dist/granular.min.js.map +7 -0
- package/package.json +54 -0
- package/src/core/bootstrap.js +63 -0
- package/src/core/collections/observable-array.js +204 -0
- package/src/core/component/function-component.js +82 -0
- package/src/core/context.js +172 -0
- package/src/core/dom/dom.js +25 -0
- package/src/core/dom/element.js +725 -0
- package/src/core/dom/error-boundary.js +111 -0
- package/src/core/dom/input-format.js +82 -0
- package/src/core/dom/list.js +185 -0
- package/src/core/dom/portal.js +57 -0
- package/src/core/dom/tags.js +182 -0
- package/src/core/dom/virtual-list.js +242 -0
- package/src/core/dom/when.js +138 -0
- package/src/core/events/event-hub.js +97 -0
- package/src/core/forms/form.js +127 -0
- package/src/core/internal/symbols.js +5 -0
- package/src/core/network/websocket.js +165 -0
- package/src/core/query/query-client.js +529 -0
- package/src/core/reactivity/after-flush.js +20 -0
- package/src/core/reactivity/computed.js +51 -0
- package/src/core/reactivity/concat.js +89 -0
- package/src/core/reactivity/dirty-host.js +162 -0
- package/src/core/reactivity/observe.js +421 -0
- package/src/core/reactivity/persist.js +180 -0
- package/src/core/reactivity/resolve.js +8 -0
- package/src/core/reactivity/signal.js +97 -0
- package/src/core/reactivity/state.js +294 -0
- package/src/core/renderable/render-string.js +51 -0
- package/src/core/renderable/renderable.js +21 -0
- package/src/core/renderable/renderer.js +66 -0
- package/src/core/router/router.js +865 -0
- package/src/core/runtime.js +28 -0
- package/src/index.js +42 -0
- package/types/core/bootstrap.d.ts +11 -0
- package/types/core/collections/observable-array.d.ts +25 -0
- package/types/core/component/function-component.d.ts +14 -0
- package/types/core/context.d.ts +29 -0
- package/types/core/dom/dom.d.ts +13 -0
- package/types/core/dom/element.d.ts +10 -0
- package/types/core/dom/error-boundary.d.ts +8 -0
- package/types/core/dom/input-format.d.ts +6 -0
- package/types/core/dom/list.d.ts +8 -0
- package/types/core/dom/portal.d.ts +8 -0
- package/types/core/dom/tags.d.ts +114 -0
- package/types/core/dom/virtual-list.d.ts +8 -0
- package/types/core/dom/when.d.ts +13 -0
- package/types/core/events/event-hub.d.ts +48 -0
- package/types/core/forms/form.d.ts +9 -0
- package/types/core/internal/symbols.d.ts +4 -0
- package/types/core/network/websocket.d.ts +18 -0
- package/types/core/query/query-client.d.ts +73 -0
- package/types/core/reactivity/after-flush.d.ts +4 -0
- package/types/core/reactivity/computed.d.ts +1 -0
- package/types/core/reactivity/concat.d.ts +1 -0
- package/types/core/reactivity/dirty-host.d.ts +42 -0
- package/types/core/reactivity/observe.d.ts +10 -0
- package/types/core/reactivity/persist.d.ts +1 -0
- package/types/core/reactivity/resolve.d.ts +1 -0
- package/types/core/reactivity/signal.d.ts +11 -0
- package/types/core/reactivity/state.d.ts +14 -0
- package/types/core/renderable/render-string.d.ts +2 -0
- package/types/core/renderable/renderable.d.ts +15 -0
- package/types/core/renderable/renderer.d.ts +38 -0
- package/types/core/router/router.d.ts +57 -0
- package/types/core/runtime.d.ts +26 -0
- package/types/index.d.ts +2 -0
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@granularjs/core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "JS-first frontend framework with granular reactivity. No Virtual DOM, no build step, no magic — just explicit reactivity and direct DOM updates.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/granular.min.js",
|
|
7
|
+
"module": "./dist/granular.min.js",
|
|
8
|
+
"types": "./types/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./types/index.d.ts",
|
|
12
|
+
"import": "./dist/granular.min.js",
|
|
13
|
+
"default": "./dist/granular.min.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"types",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build:minify": "npx -y esbuild src/index.js --bundle --minify --sourcemap --format=esm --outfile=./dist/granular.min.js && npx -y -p typescript@latest tsc -p tsconfig.json",
|
|
23
|
+
"build:debug": "npx -y esbuild src/index.js --bundle --sourcemap --format=esm --outfile=./dist/granular.js && npx -y -p typescript@latest tsc -p tsconfig.json",
|
|
24
|
+
"build:watch": "npx -y esbuild src/index.js --bundle --minify --sourcemap --format=esm --outfile=./dist/granular.min.js --watch",
|
|
25
|
+
"version": "npm run build:minify && git add -A",
|
|
26
|
+
"postversion": "git push && git push --tags"
|
|
27
|
+
},
|
|
28
|
+
"author": "Guilherme Ferreira",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/zerobytes/granular.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://granular.web.app",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/zerobytes/granular/issues"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"granular",
|
|
40
|
+
"granularjs",
|
|
41
|
+
"framework",
|
|
42
|
+
"frontend",
|
|
43
|
+
"reactive",
|
|
44
|
+
"reactivity",
|
|
45
|
+
"signals",
|
|
46
|
+
"state",
|
|
47
|
+
"dom",
|
|
48
|
+
"no-vdom",
|
|
49
|
+
"no-virtual-dom",
|
|
50
|
+
"fine-grained",
|
|
51
|
+
"javascript",
|
|
52
|
+
"ui"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Renderer } from './renderable/renderer.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates and attaches a component instance or render function to a target element.
|
|
5
|
+
*
|
|
6
|
+
* @template T
|
|
7
|
+
* @param {new (...args: any[]) => T | (() => any)} ComponentClass
|
|
8
|
+
* @param {string|Element} target
|
|
9
|
+
* @returns {Promise<T|{ unmount(): void }>}
|
|
10
|
+
*/
|
|
11
|
+
export async function bootstrap(ComponentClass, target) {
|
|
12
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
13
|
+
if (!el) throw new Error('bootstrap target not found');
|
|
14
|
+
|
|
15
|
+
if (typeof ComponentClass !== 'function') {
|
|
16
|
+
throw new Error('bootstrap: component must be a function or class');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let instance = null;
|
|
20
|
+
if (ComponentClass.__zbFactory) {
|
|
21
|
+
instance = ComponentClass();
|
|
22
|
+
} else {
|
|
23
|
+
try {
|
|
24
|
+
instance = new ComponentClass();
|
|
25
|
+
} catch {
|
|
26
|
+
instance = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (instance) {
|
|
31
|
+
if (typeof instance.attach === 'function') {
|
|
32
|
+
await instance.attach(el);
|
|
33
|
+
return instance;
|
|
34
|
+
}
|
|
35
|
+
if (typeof instance.mountInto === 'function') {
|
|
36
|
+
instance.mountInto(el, null);
|
|
37
|
+
return instance;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const root = ComponentClass();
|
|
42
|
+
const values = Renderer.normalize(root);
|
|
43
|
+
for (const r of values) {
|
|
44
|
+
if (Renderer.isRenderable(r)) {
|
|
45
|
+
r.mountInto(el, null);
|
|
46
|
+
} else if (Renderer.isDomNode(r)) {
|
|
47
|
+
el.appendChild(r);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
unmount() {
|
|
53
|
+
for (const r of values) {
|
|
54
|
+
if (Renderer.isRenderable(r)) {
|
|
55
|
+
r.unmount();
|
|
56
|
+
} else if (Renderer.isDomNode(r)) {
|
|
57
|
+
r.remove();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { EventHub } from '../events/event-hub.js';
|
|
2
|
+
|
|
3
|
+
const ObservableArrayMeta = new WeakMap();
|
|
4
|
+
|
|
5
|
+
export function isObservableArray(value) {
|
|
6
|
+
return !!value && typeof value === 'object' && ObservableArrayMeta.has(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function clampIndex(index, length) {
|
|
10
|
+
if (index < 0) return Math.max(0, length + index);
|
|
11
|
+
return Math.min(index, length);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function observableArray(initial = []) {
|
|
15
|
+
const target = Array.isArray(initial) ? initial.slice() : [];
|
|
16
|
+
const subs = new Set();
|
|
17
|
+
const hub = new EventHub();
|
|
18
|
+
|
|
19
|
+
const notify = (patch, ctx) => {
|
|
20
|
+
for (const fn of subs) fn(patch, ctx);
|
|
21
|
+
hub.emitAfter(patch.type, patch, ctx || { array: proxy });
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const proxy = new Proxy(target, {
|
|
25
|
+
get(t, prop, receiver) {
|
|
26
|
+
// Public API (preferred)
|
|
27
|
+
if (prop === 'subscribe') {
|
|
28
|
+
return (fn) => {
|
|
29
|
+
subs.add(fn);
|
|
30
|
+
return () => subs.delete(fn);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (prop === 'reset') {
|
|
34
|
+
return (nextArray) => {
|
|
35
|
+
const prevItems = t.slice();
|
|
36
|
+
const nextItems = Array.isArray(nextArray) ? nextArray.slice() : [];
|
|
37
|
+
const ctx = { array: proxy, op: 'reset', args: [nextArray], prevLength: t.length, nextLength: nextItems.length };
|
|
38
|
+
const patch = { type: 'reset', items: nextItems, prevItems };
|
|
39
|
+
if (!hub.emitBefore('reset', patch, ctx)) return;
|
|
40
|
+
t.length = 0;
|
|
41
|
+
if (Array.isArray(nextArray)) t.push(...nextArray);
|
|
42
|
+
notify({ type: 'reset', items: t.slice(), prevItems }, ctx);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (prop === 'after') {
|
|
46
|
+
return () => hub.phase('after');
|
|
47
|
+
}
|
|
48
|
+
if (prop === 'before') {
|
|
49
|
+
return () => hub.phase('before');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const value = Reflect.get(t, prop, receiver);
|
|
53
|
+
if (typeof value !== 'function') return value;
|
|
54
|
+
|
|
55
|
+
if (prop === 'push') {
|
|
56
|
+
return (...items) => {
|
|
57
|
+
const index = t.length;
|
|
58
|
+
const ctx = { array: proxy, op: 'push', args: items, prevLength: t.length, nextLength: t.length + items.length };
|
|
59
|
+
const patch = { type: 'insert', index, items: items.slice() };
|
|
60
|
+
if (items.length && !hub.emitBefore('insert', patch, ctx)) return t.length;
|
|
61
|
+
const result = Array.prototype.push.apply(t, items);
|
|
62
|
+
if (items.length) notify({ type: 'insert', index, items }, ctx);
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (prop === 'pop') {
|
|
67
|
+
return () => {
|
|
68
|
+
if (t.length === 0) return undefined;
|
|
69
|
+
const index = t.length - 1;
|
|
70
|
+
const removed = [t[index]];
|
|
71
|
+
const ctx = { array: proxy, op: 'pop', args: [], prevLength: t.length, nextLength: t.length - 1 };
|
|
72
|
+
const patch = { type: 'remove', index, count: 1, items: removed };
|
|
73
|
+
if (!hub.emitBefore('remove', patch, ctx)) return undefined;
|
|
74
|
+
const result = Array.prototype.pop.apply(t);
|
|
75
|
+
notify({ type: 'remove', index, count: 1, items: removed }, ctx);
|
|
76
|
+
return result;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (prop === 'unshift') {
|
|
80
|
+
return (...items) => {
|
|
81
|
+
const ctx = { array: proxy, op: 'unshift', args: items, prevLength: t.length, nextLength: t.length + items.length };
|
|
82
|
+
const patch = { type: 'insert', index: 0, items: items.slice() };
|
|
83
|
+
if (items.length && !hub.emitBefore('insert', patch, ctx)) return t.length;
|
|
84
|
+
const result = Array.prototype.unshift.apply(t, items);
|
|
85
|
+
if (items.length) notify({ type: 'insert', index: 0, items }, ctx);
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (prop === 'shift') {
|
|
90
|
+
return () => {
|
|
91
|
+
if (t.length === 0) return undefined;
|
|
92
|
+
const removed = [t[0]];
|
|
93
|
+
const ctx = { array: proxy, op: 'shift', args: [], prevLength: t.length, nextLength: t.length - 1 };
|
|
94
|
+
const patch = { type: 'remove', index: 0, count: 1, items: removed };
|
|
95
|
+
if (!hub.emitBefore('remove', patch, ctx)) return undefined;
|
|
96
|
+
const result = Array.prototype.shift.apply(t);
|
|
97
|
+
notify({ type: 'remove', index: 0, count: 1, items: removed }, ctx);
|
|
98
|
+
return result;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (prop === 'splice') {
|
|
102
|
+
return (start, deleteCount, ...items) => {
|
|
103
|
+
const lenBefore = t.length;
|
|
104
|
+
const index = clampIndex(Number(start) || 0, lenBefore);
|
|
105
|
+
const dc =
|
|
106
|
+
deleteCount === undefined ? lenBefore - index : Math.max(0, Number(deleteCount) || 0);
|
|
107
|
+
const ctx = { array: proxy, op: 'splice', args: [start, deleteCount, ...items], prevLength: t.length, nextLength: t.length - dc + items.length };
|
|
108
|
+
if (dc) {
|
|
109
|
+
const removePatch = { type: 'remove', index, count: dc, items: t.slice(index, index + dc) };
|
|
110
|
+
if (!hub.emitBefore('remove', removePatch, ctx)) return [];
|
|
111
|
+
}
|
|
112
|
+
if (items.length) {
|
|
113
|
+
const insertPatch = { type: 'insert', index, items: items.slice() };
|
|
114
|
+
if (!hub.emitBefore('insert', insertPatch, ctx)) return [];
|
|
115
|
+
}
|
|
116
|
+
const removed = Array.prototype.splice.apply(t, [index, dc, ...items]);
|
|
117
|
+
if (dc) notify({ type: 'remove', index, count: dc, items: removed }, ctx);
|
|
118
|
+
if (items.length) notify({ type: 'insert', index, items }, ctx);
|
|
119
|
+
return removed;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return value.bind(t);
|
|
124
|
+
},
|
|
125
|
+
set(t, prop, value, receiver) {
|
|
126
|
+
if (prop === 'length') {
|
|
127
|
+
const prev = t.length;
|
|
128
|
+
const next = Number(value) || 0;
|
|
129
|
+
const prevItems = t.slice();
|
|
130
|
+
const removed = next < prev ? t.slice(next, prev) : [];
|
|
131
|
+
const ctx = { array: proxy, op: 'length', args: [next], prevLength: prev, nextLength: next };
|
|
132
|
+
const ok = Reflect.set(t, prop, next, receiver);
|
|
133
|
+
if (ok && next < prev) {
|
|
134
|
+
const patch = { type: 'remove', index: next, count: prev - next, items: removed };
|
|
135
|
+
if (hub.emitBefore('remove', patch, ctx)) notify(patch, ctx);
|
|
136
|
+
}
|
|
137
|
+
if (ok && next > prev) {
|
|
138
|
+
notify({ type: 'reset', items: t.slice(), prevItems }, ctx);
|
|
139
|
+
}
|
|
140
|
+
return ok;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const index = typeof prop === 'string' && /^\d+$/.test(prop) ? Number(prop) : null;
|
|
144
|
+
if (index == null) return Reflect.set(t, prop, value, receiver);
|
|
145
|
+
|
|
146
|
+
const lenBefore = t.length;
|
|
147
|
+
const prevValue = index < t.length ? t[index] : undefined;
|
|
148
|
+
const ctx = { array: proxy, op: 'set', args: [prop, value], prevLength: t.length, nextLength: t.length };
|
|
149
|
+
const ok = Reflect.set(t, prop, value, receiver);
|
|
150
|
+
if (!ok) return false;
|
|
151
|
+
|
|
152
|
+
if (index < lenBefore) {
|
|
153
|
+
const patch = { type: 'set', index, value, prev: prevValue };
|
|
154
|
+
if (hub.emitBefore('set', patch, ctx)) notify(patch, ctx);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (index === lenBefore) {
|
|
159
|
+
const patch = { type: 'insert', index, items: [value] };
|
|
160
|
+
ctx.nextLength = t.length;
|
|
161
|
+
if (hub.emitBefore('insert', patch, ctx)) notify(patch, ctx);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const prevItems = t.slice(0, lenBefore);
|
|
166
|
+
notify({ type: 'reset', items: t.slice(), prevItems }, ctx);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
ObservableArrayMeta.set(proxy, { target, subs });
|
|
172
|
+
return proxy;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @typedef {Object} ObservableArrayPatchInsert
|
|
177
|
+
* @property {'insert'} type
|
|
178
|
+
* @property {number} index
|
|
179
|
+
* @property {any[]} items
|
|
180
|
+
*/
|
|
181
|
+
/**
|
|
182
|
+
* @typedef {Object} ObservableArrayPatchRemove
|
|
183
|
+
* @property {'remove'} type
|
|
184
|
+
* @property {number} index
|
|
185
|
+
* @property {number} count
|
|
186
|
+
* @property {any[]} items
|
|
187
|
+
*/
|
|
188
|
+
/**
|
|
189
|
+
* @typedef {Object} ObservableArrayPatchSet
|
|
190
|
+
* @property {'set'} type
|
|
191
|
+
* @property {number} index
|
|
192
|
+
* @property {any} value
|
|
193
|
+
* @property {any} prev
|
|
194
|
+
*/
|
|
195
|
+
/**
|
|
196
|
+
* @typedef {Object} ObservableArrayPatchReset
|
|
197
|
+
* @property {'reset'} type
|
|
198
|
+
* @property {any[]} items
|
|
199
|
+
* @property {any[]} prevItems
|
|
200
|
+
*/
|
|
201
|
+
/**
|
|
202
|
+
* @typedef {ObservableArrayPatchInsert|ObservableArrayPatchRemove|ObservableArrayPatchSet|ObservableArrayPatchReset} ObservableArrayPatch
|
|
203
|
+
*/
|
|
204
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DirtyHost } from '../reactivity/dirty-host.js';
|
|
2
|
+
import { Renderer } from '../renderable/renderer.js';
|
|
3
|
+
|
|
4
|
+
function createProxy(host) {
|
|
5
|
+
return new Proxy(host, {
|
|
6
|
+
get: (target, prop) => {
|
|
7
|
+
if (prop === 'onCleanup') return target.onCleanup.bind(target);
|
|
8
|
+
if (prop === '$') {
|
|
9
|
+
return (name) => target[name];
|
|
10
|
+
}
|
|
11
|
+
if (typeof prop === 'string' && prop.startsWith('$')) {
|
|
12
|
+
return target[prop.slice(1)];
|
|
13
|
+
}
|
|
14
|
+
const value = target[prop];
|
|
15
|
+
if (typeof value === 'function') return value.bind(target);
|
|
16
|
+
return value;
|
|
17
|
+
},
|
|
18
|
+
set: (target, prop, value) => {
|
|
19
|
+
target[prop] = value;
|
|
20
|
+
return true;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class FunctionComponentInstance extends DirtyHost {
|
|
26
|
+
#root = null;
|
|
27
|
+
#rootValues = [];
|
|
28
|
+
#cleanups = [];
|
|
29
|
+
|
|
30
|
+
constructor(renderFn, props) {
|
|
31
|
+
super();
|
|
32
|
+
this.props = props || {};
|
|
33
|
+
const proxy = createProxy(this);
|
|
34
|
+
const root = renderFn.call(proxy, props);
|
|
35
|
+
this.#root = root;
|
|
36
|
+
this.#rootValues = Renderer.normalize(root);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
mountInto(parent, beforeNode) {
|
|
40
|
+
for (const r of this.#rootValues) {
|
|
41
|
+
if (Renderer.isRenderable(r)) {
|
|
42
|
+
r.mountInto(parent, beforeNode);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (Renderer.isDomNode(r)) {
|
|
46
|
+
parent.insertBefore(r, beforeNode);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
unmount() {
|
|
52
|
+
for (const fn of this.#cleanups) fn();
|
|
53
|
+
this.#cleanups = [];
|
|
54
|
+
for (const r of this.#rootValues) {
|
|
55
|
+
if (Renderer.isRenderable(r)) {
|
|
56
|
+
r.unmount();
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (Renderer.isDomNode(r)) {
|
|
60
|
+
r.remove();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
renderToString(render) {
|
|
66
|
+
return render(this.#root);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
onCleanup(fn) {
|
|
70
|
+
if (typeof fn !== 'function') return;
|
|
71
|
+
this.#cleanups.push(fn);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function component(renderFn) {
|
|
76
|
+
if (typeof renderFn !== 'function') {
|
|
77
|
+
throw new Error('component(fn): fn must be a function');
|
|
78
|
+
}
|
|
79
|
+
const factory = (props) => new FunctionComponentInstance(renderFn, props);
|
|
80
|
+
factory.__zbFactory = true;
|
|
81
|
+
return factory;
|
|
82
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { signal, readSignal, setSignal, subscribeSignal } from './reactivity/signal.js';
|
|
2
|
+
import { createStateFromAdapter } from './reactivity/state.js';
|
|
3
|
+
import { Renderable } from './renderable/renderable.js';
|
|
4
|
+
|
|
5
|
+
class ContextProvider extends Renderable {
|
|
6
|
+
#child;
|
|
7
|
+
#providerSignal;
|
|
8
|
+
#consumers;
|
|
9
|
+
#mountStack;
|
|
10
|
+
#mountTimeConsumers = [];
|
|
11
|
+
#mounted = false;
|
|
12
|
+
|
|
13
|
+
constructor(child, providerSignal, consumers, mountStack) {
|
|
14
|
+
super();
|
|
15
|
+
this.#child = child;
|
|
16
|
+
this.#providerSignal = providerSignal;
|
|
17
|
+
this.#consumers = consumers;
|
|
18
|
+
this.#mountStack = mountStack;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
mountInto(parent, beforeNode) {
|
|
22
|
+
if (this.#mounted) return;
|
|
23
|
+
this.#mounted = true;
|
|
24
|
+
for (const consumer of this.#consumers) {
|
|
25
|
+
consumer._connect(this.#providerSignal);
|
|
26
|
+
}
|
|
27
|
+
this.#mountStack.push({ signal: this.#providerSignal, consumers: this.#mountTimeConsumers });
|
|
28
|
+
this.#child.mountInto(parent, beforeNode);
|
|
29
|
+
this.#mountStack.pop();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
unmount() {
|
|
33
|
+
if (!this.#mounted) return;
|
|
34
|
+
this.#mounted = false;
|
|
35
|
+
this.#child.unmount();
|
|
36
|
+
for (const consumer of this.#consumers) {
|
|
37
|
+
consumer._disconnect();
|
|
38
|
+
}
|
|
39
|
+
for (const consumer of this.#mountTimeConsumers) {
|
|
40
|
+
consumer._disconnect();
|
|
41
|
+
}
|
|
42
|
+
this.#mountTimeConsumers = [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
renderToString(render) {
|
|
46
|
+
for (const consumer of this.#consumers) {
|
|
47
|
+
consumer._connect(this.#providerSignal);
|
|
48
|
+
}
|
|
49
|
+
this.#mountStack.push({ signal: this.#providerSignal, consumers: this.#mountTimeConsumers });
|
|
50
|
+
const html = render(this.#child);
|
|
51
|
+
this.#mountStack.pop();
|
|
52
|
+
return html;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createContextConsumer(defaultValue) {
|
|
57
|
+
const localSignal = signal(defaultValue);
|
|
58
|
+
const subscribers = new Set();
|
|
59
|
+
let activeProviderSignal = null;
|
|
60
|
+
let providerUnsub = null;
|
|
61
|
+
let localUnsub = null;
|
|
62
|
+
|
|
63
|
+
const notify = (...args) => {
|
|
64
|
+
for (const fn of subscribers) fn(...args);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
localUnsub = subscribeSignal(localSignal, notify);
|
|
68
|
+
|
|
69
|
+
const getActive = () => activeProviderSignal || localSignal;
|
|
70
|
+
|
|
71
|
+
const adapter = {
|
|
72
|
+
kind: 'state',
|
|
73
|
+
get: () => readSignal(getActive()),
|
|
74
|
+
set: (next) => setSignal(getActive(), next, true),
|
|
75
|
+
subscribe: (fn) => {
|
|
76
|
+
subscribers.add(fn);
|
|
77
|
+
return () => subscribers.delete(fn);
|
|
78
|
+
},
|
|
79
|
+
before: localSignal.before,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const consumerState = createStateFromAdapter(adapter);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
state: consumerState,
|
|
86
|
+
_connect(providerSignal) {
|
|
87
|
+
activeProviderSignal = providerSignal;
|
|
88
|
+
if (localUnsub) { localUnsub(); localUnsub = null; }
|
|
89
|
+
providerUnsub = subscribeSignal(providerSignal, notify);
|
|
90
|
+
const newVal = readSignal(providerSignal);
|
|
91
|
+
const oldVal = readSignal(localSignal);
|
|
92
|
+
if (newVal !== oldVal) {
|
|
93
|
+
notify(newVal, oldVal);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
_disconnect() {
|
|
97
|
+
if (providerUnsub) { providerUnsub(); providerUnsub = null; }
|
|
98
|
+
activeProviderSignal = null;
|
|
99
|
+
localUnsub = subscribeSignal(localSignal, notify);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Creates a context for sharing reactive state across a component tree
|
|
106
|
+
* without prop drilling.
|
|
107
|
+
*
|
|
108
|
+
* Returns { scope, state }:
|
|
109
|
+
* - scope(value?) — creates a new provider level. Returns a state with
|
|
110
|
+
* .get(), .set(), path access, and .serve(renderable) to wrap children.
|
|
111
|
+
* - state() — returns a reactive state bound to the nearest ancestor provider.
|
|
112
|
+
*
|
|
113
|
+
* Usage:
|
|
114
|
+
* const sizeCtx = context([1, 2, 3]);
|
|
115
|
+
*
|
|
116
|
+
* const Parent = (...children) => {
|
|
117
|
+
* const sizes = sizeCtx.scope();
|
|
118
|
+
* sizes.set([10, 20, 30]);
|
|
119
|
+
* return sizes.serve(Div(...children));
|
|
120
|
+
* };
|
|
121
|
+
*
|
|
122
|
+
* const Child = () => {
|
|
123
|
+
* const sizes = sizeCtx.state();
|
|
124
|
+
* return Div(sizes[0]);
|
|
125
|
+
* };
|
|
126
|
+
*
|
|
127
|
+
* Parent(Child());
|
|
128
|
+
*/
|
|
129
|
+
export function context(defaultValue) {
|
|
130
|
+
const pending = [];
|
|
131
|
+
const mountStack = [];
|
|
132
|
+
|
|
133
|
+
const scope = (value) => {
|
|
134
|
+
const providerSignal = signal(value !== undefined ? value : defaultValue);
|
|
135
|
+
|
|
136
|
+
const adapter = {
|
|
137
|
+
kind: 'state',
|
|
138
|
+
get: () => readSignal(providerSignal),
|
|
139
|
+
set: (next) => setSignal(providerSignal, next, true),
|
|
140
|
+
subscribe: (fn) => subscribeSignal(providerSignal, fn),
|
|
141
|
+
before: providerSignal.before,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const providerState = createStateFromAdapter(adapter);
|
|
145
|
+
|
|
146
|
+
const serve = (renderable) => {
|
|
147
|
+
const consumers = pending.splice(0, pending.length);
|
|
148
|
+
return new ContextProvider(renderable, providerSignal, consumers, mountStack);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return new Proxy(providerState, {
|
|
152
|
+
get(target, prop) {
|
|
153
|
+
if (prop === 'serve') return serve;
|
|
154
|
+
return Reflect.get(target, prop);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const state = () => {
|
|
160
|
+
const consumer = createContextConsumer(defaultValue);
|
|
161
|
+
if (mountStack.length > 0) {
|
|
162
|
+
const top = mountStack[mountStack.length - 1];
|
|
163
|
+
consumer._connect(top.signal);
|
|
164
|
+
top.consumers.push(consumer);
|
|
165
|
+
} else {
|
|
166
|
+
pending.push(consumer);
|
|
167
|
+
}
|
|
168
|
+
return consumer.state;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return { scope, state };
|
|
172
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a comment node used as a stable DOM anchor.
|
|
3
|
+
* @param {string} label
|
|
4
|
+
* @param {string} name
|
|
5
|
+
*/
|
|
6
|
+
export function createComment(label, name) {
|
|
7
|
+
return document.createComment(`${label}:${name}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Removes all sibling nodes between two anchors.
|
|
12
|
+
* @param {Comment} start
|
|
13
|
+
* @param {Comment} end
|
|
14
|
+
* @param {(node: Node) => void} [disposer]
|
|
15
|
+
*/
|
|
16
|
+
export function clearBetween(start, end, disposer) {
|
|
17
|
+
let current = start.nextSibling;
|
|
18
|
+
while (current && current !== end) {
|
|
19
|
+
const next = current.nextSibling;
|
|
20
|
+
disposer?.(current);
|
|
21
|
+
current.remove();
|
|
22
|
+
current = next;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|