@daz4126/helium 0.8.0 → 0.10.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/helium.js +134 -73
- package/package.json +1 -1
package/helium.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export default function helium(data = {}) {
|
|
2
2
|
const root = document.querySelector("[\\@helium]") || document.querySelector("[data-helium]") || document.body;
|
|
3
|
-
const [bindings, refs] = [new Map(), new Map()];
|
|
3
|
+
const [bindings, refs, listeners, proxyCache] = [new Map(), new Map(), new WeakMap(), new WeakMap()];
|
|
4
4
|
const $ = (s) => document.querySelector(s);
|
|
5
5
|
const html = (s) => {
|
|
6
6
|
const t = document.createElement("template");
|
|
@@ -8,31 +8,36 @@ export default function helium(data = {}) {
|
|
|
8
8
|
return t.content.firstChild;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
const ajax = (url, method, target, opts = {}) => {
|
|
11
|
+
const ajax = (url, method, target, opts = {}) => {
|
|
12
|
+
const headers = {
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
"Accept": "text/vnd.turbo-stream.html,application/json,text/html"
|
|
15
|
+
};
|
|
16
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
17
|
+
if (token) headers["X-CSRF-Token"] = token;
|
|
18
|
+
|
|
12
19
|
fetch(url, {
|
|
13
20
|
method,
|
|
14
|
-
headers
|
|
15
|
-
"Content-Type": "application/json",
|
|
16
|
-
Accept: "text/vnd.turbo-stream.html, application/json, application/vnd.api+json, text/html, */*",
|
|
17
|
-
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content || ""
|
|
18
|
-
},
|
|
21
|
+
headers,
|
|
19
22
|
body: method === "GET" ? null : JSON.stringify(opts),
|
|
20
23
|
credentials: "same-origin"
|
|
21
24
|
})
|
|
22
|
-
.then(r =>
|
|
23
|
-
r.headers.get("content-type")?.includes("application/json") ? r.json() : r.text()
|
|
24
|
-
)
|
|
25
|
+
.then(r => r.headers.get("content-type")?.includes("application/json") ? r.json() : r.text())
|
|
25
26
|
.then(d => state[target] = d)
|
|
26
27
|
.catch(e => console.error("AJAX error:", e.message));
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
const get = (u,t) => ajax(u,"GET",t);
|
|
30
|
-
const [post, put, patch, del] = ["POST","PUT","PATCH","DELETE"].map(
|
|
31
|
+
const [post, put, patch, del] = ["POST","PUT","PATCH","DELETE"].map(m => (u, d, t) => ajax(u, m, t, d));
|
|
31
32
|
|
|
32
33
|
const handler = {
|
|
33
34
|
get(target, prop, receiver) {
|
|
34
35
|
const val = Reflect.get(target, prop, receiver);
|
|
35
|
-
|
|
36
|
+
if (typeof val === "object" && val !== null) {
|
|
37
|
+
if (!proxyCache.has(val)) proxyCache.set(val, new Proxy(val, handler));
|
|
38
|
+
return proxyCache.get(val);
|
|
39
|
+
}
|
|
40
|
+
return val;
|
|
36
41
|
},
|
|
37
42
|
set(target, prop, val, receiver) {
|
|
38
43
|
const result = Reflect.set(target, prop, val, receiver);
|
|
@@ -59,120 +64,164 @@ export default function helium(data = {}) {
|
|
|
59
64
|
isUpdatingDOM = true;
|
|
60
65
|
el.innerHTML = result;
|
|
61
66
|
isUpdatingDOM = false;
|
|
62
|
-
} else if(prop == "class" && typeof
|
|
63
|
-
const add = [];
|
|
64
|
-
const remove = [];
|
|
65
|
-
|
|
67
|
+
} else if(prop == "class" && typeof result == "object"){
|
|
68
|
+
const [add, remove] = [[], []];
|
|
66
69
|
for (const [key, value] of Object.entries(result)) {
|
|
67
70
|
(value ? add : remove).push(key);
|
|
68
71
|
}
|
|
69
|
-
|
|
70
72
|
el.classList.add(...add);
|
|
71
73
|
el.classList.remove(...remove);
|
|
72
|
-
} else if(prop == "style" && typeof
|
|
73
|
-
el.style = Object.entries(result).
|
|
74
|
-
|
|
75
|
-
el
|
|
74
|
+
} else if(prop == "style" && typeof result == "object"){
|
|
75
|
+
el.style = Object.entries(result).filter(([k, v]) => v).map(([k, v]) => `${k}:${v}`).join(";");
|
|
76
|
+
} else if (prop in el) {
|
|
77
|
+
if(el.type == "radio") el.checked = el.value == result;
|
|
78
|
+
else el[prop] = result;
|
|
76
79
|
} else {
|
|
77
80
|
el.setAttribute(prop, result);
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
const compile = (expr, withReturn = false) => {
|
|
82
85
|
try {
|
|
83
86
|
return new Function("$", "$data", "$event", "$el", "$html", "$get", "$post", "$put", "$patch", "$delete",
|
|
84
87
|
...Object.keys(data), ...[...refs.keys()],
|
|
85
|
-
`with($data)
|
|
88
|
+
`with($data){${withReturn ? "return" : ""}(${expr.trim()})}`);
|
|
86
89
|
} catch (err) {
|
|
87
90
|
return () => expr;
|
|
88
91
|
}
|
|
89
|
-
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Track which state properties an expression depends on
|
|
95
|
+
const trackDependencies = (fn, el) => {
|
|
96
|
+
const accessed = new Set();
|
|
97
|
+
const trackingHandler = {
|
|
98
|
+
get(target, prop, receiver) {
|
|
99
|
+
if (prop !== Symbol.toStringTag && typeof prop === 'string') {
|
|
100
|
+
accessed.add(prop);
|
|
101
|
+
}
|
|
102
|
+
const val = Reflect.get(target, prop, receiver);
|
|
103
|
+
return typeof val === "object" && val !== null ? new Proxy(val, trackingHandler) : val;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const trackingProxy = new Proxy(data, trackingHandler);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
fn($, trackingProxy, {}, el, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// Expression might fail on first run, that's ok
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return [...accessed];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const cleanup = (el) => {
|
|
119
|
+
const clean = (e) => {
|
|
120
|
+
if (listeners.has(e)) {
|
|
121
|
+
listeners.get(e).forEach(({receiver, eventName, handler}) => {
|
|
122
|
+
receiver.removeEventListener(eventName, handler);
|
|
123
|
+
});
|
|
124
|
+
listeners.delete(e);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
clean(el);
|
|
128
|
+
el.querySelectorAll('*').forEach(clean);
|
|
129
|
+
};
|
|
90
130
|
|
|
91
131
|
function processElements(element) {
|
|
92
132
|
const newBindings = [];
|
|
93
|
-
if (element.nodeType === 1
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
Array.from(el.attributes).some((attr) => attr.name.startsWith("data-he-")))];
|
|
133
|
+
if (element.nodeType === 1) {
|
|
134
|
+
if (element.hasAttribute?.("data-helium-processed")) return newBindings;
|
|
135
|
+
element.setAttribute("data-helium-processed", "");
|
|
136
|
+
}
|
|
98
137
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
138
|
+
const heElements = [element, ...element.querySelectorAll("*")].filter(el =>
|
|
139
|
+
Array.from(el.attributes).some(attr =>
|
|
140
|
+
attr.name.startsWith("@") || attr.name.startsWith(":") || attr.name.startsWith("data-he-")
|
|
141
|
+
)
|
|
142
|
+
);
|
|
102
143
|
|
|
103
144
|
// Seed default state values
|
|
104
|
-
heElements.forEach(
|
|
145
|
+
heElements.forEach(el => {
|
|
105
146
|
for (const { name, value } of el.attributes || []) {
|
|
106
147
|
if (["@html","data-he-html","@text","data-he-text","@bind","data-he-bind"].includes(name)) {
|
|
107
148
|
try {
|
|
108
|
-
new Function(`let ${value}
|
|
149
|
+
new Function(`let ${value}=1`);
|
|
109
150
|
state[value] ||= ["@bind","data-he-bind"].includes(name) ? el.value : el.textContent;
|
|
110
151
|
} catch (e) {}
|
|
111
152
|
}
|
|
112
153
|
}
|
|
113
154
|
});
|
|
114
155
|
|
|
156
|
+
const addBinding = (val, b) => {
|
|
157
|
+
bindings.set(val, [...(bindings.get(val) || []), b]);
|
|
158
|
+
newBindings.push(b);
|
|
159
|
+
};
|
|
115
160
|
|
|
116
161
|
// Register bindings
|
|
117
|
-
heElements.forEach(
|
|
118
|
-
|
|
162
|
+
heElements.forEach(el => {
|
|
163
|
+
const execFn = v => compile(v, true)($, state, {}, el, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
|
|
119
164
|
for (const { name, value } of el.attributes || []) {
|
|
120
165
|
if (["@data","data-he"].includes(name)) Object.assign(state, execFn(value));
|
|
121
166
|
if (["@ref","data-he-ref"].includes(name)) refs.set("$" + value, el);
|
|
122
167
|
|
|
123
168
|
if (["@text","data-he-text","@html","data-he-html"].includes(name)) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
169
|
+
const fn = compile(value, true);
|
|
170
|
+
const deps = trackDependencies(fn, el);
|
|
171
|
+
const b = { el, prop: ["@text","data-he-text"].includes(name) ? "textContent" : "innerHTML", expr: value, fn };
|
|
172
|
+
|
|
173
|
+
deps.forEach(dep => addBinding(dep, b));
|
|
129
174
|
}
|
|
130
175
|
|
|
131
|
-
if (["@bind","data-he-bind"].includes(name)) {
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
176
|
+
if (["@bind", "data-he-bind"].includes(name)) {
|
|
177
|
+
const inputType = el.type?.toLowerCase();
|
|
178
|
+
const isCheckbox = inputType === "checkbox";
|
|
179
|
+
const isRadio = inputType === "radio";
|
|
180
|
+
const event = (isCheckbox || isRadio || el.tagName === "SELECT") ? "change" : "input";
|
|
181
|
+
const prop = isCheckbox ? "checked" : "value";
|
|
182
|
+
|
|
183
|
+
const inputHandler = e => state[value] = isCheckbox ? e.target.checked : e.target.value;
|
|
184
|
+
el.addEventListener(event, inputHandler);
|
|
185
|
+
if (!listeners.has(el)) listeners.set(el, []);
|
|
186
|
+
listeners.get(el).push({receiver: el, eventName: event, handler: inputHandler});
|
|
187
|
+
|
|
188
|
+
addBinding(value, { el, prop, expr: value, fn: compile(value, true) });
|
|
189
|
+
|
|
190
|
+
if (isCheckbox) el.checked = !!state[value];
|
|
191
|
+
else if (isRadio) el.checked = el.value == state[value];
|
|
192
|
+
else el.value = state[value] ?? "";
|
|
137
193
|
}
|
|
138
194
|
|
|
139
195
|
if (["@hidden","@visible","data-he-hidden","data-he-visible"].includes(name)) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
196
|
+
const fn = compile(`${["@hidden","data-he-hidden"].includes(name) ? "!" : ""}!(${value})`, true);
|
|
197
|
+
const deps = trackDependencies(fn, el);
|
|
198
|
+
const b = { el, prop: "hidden", expr: value, fn };
|
|
199
|
+
|
|
200
|
+
deps.forEach(dep => addBinding(dep, b));
|
|
145
201
|
}
|
|
146
202
|
|
|
147
203
|
if (name.startsWith(":")) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
const fn = compile(value, true);
|
|
205
|
+
const deps = trackDependencies(fn, el);
|
|
206
|
+
const b = { el, prop: name.slice(1), expr: value, fn };
|
|
207
|
+
|
|
208
|
+
deps.forEach(dep => addBinding(dep, b));
|
|
153
209
|
}
|
|
154
210
|
|
|
155
211
|
if (["@init","data-he-init"].includes(name)) {
|
|
156
|
-
|
|
212
|
+
compile(value, false)($, state, undefined, el, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
|
|
157
213
|
} else if (name.startsWith("@") || name.startsWith("data-he-on")) {
|
|
158
214
|
const [eventName, ...mods] = name.slice(name.startsWith("@") ? 1 : 10).split(".");
|
|
159
215
|
const receiver = mods.includes("outside") || mods.includes("document") ? document : el;
|
|
160
|
-
|
|
161
|
-
let timeout;
|
|
162
|
-
return (...args) => {
|
|
163
|
-
clearTimeout(timeout);
|
|
164
|
-
timeout = setTimeout(() => fn(...args), delay);
|
|
165
|
-
};
|
|
166
|
-
};
|
|
167
|
-
|
|
216
|
+
|
|
168
217
|
let debounceDelay = 0;
|
|
169
|
-
const debounceMod = mods.find(
|
|
218
|
+
const debounceMod = mods.find(m => m.startsWith("debounce"));
|
|
170
219
|
if (debounceMod) {
|
|
171
220
|
const time = debounceMod.split(":")[1];
|
|
172
221
|
debounceDelay = time && !isNaN(time) ? Number(time) : 300;
|
|
173
222
|
}
|
|
174
223
|
|
|
175
|
-
|
|
224
|
+
const _handler = e => {
|
|
176
225
|
if (mods.includes("prevent")) e.preventDefault();
|
|
177
226
|
const keyMods = { shift: "shiftKey", ctrl: "ctrlKey", alt: "altKey", meta: "metaKey" };
|
|
178
227
|
for (const [mod, prop] of Object.entries(keyMods)) {
|
|
@@ -186,13 +235,23 @@ el.style = Object.entries(result).map(([key, value]) => value ? `${key}: ${value
|
|
|
186
235
|
}
|
|
187
236
|
}
|
|
188
237
|
if (!mods.includes("outside") || !el.contains(e.target)) {
|
|
189
|
-
|
|
238
|
+
compile(value, false)($, state, e, el, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
|
|
190
239
|
}
|
|
191
240
|
if (mods.includes("once")) el.removeEventListener(eventName, debouncedHandler);
|
|
192
|
-
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const debounce = (fn, delay) => {
|
|
244
|
+
let timeout;
|
|
245
|
+
return (...args) => {
|
|
246
|
+
clearTimeout(timeout);
|
|
247
|
+
timeout = setTimeout(() => fn(...args), delay);
|
|
248
|
+
};
|
|
249
|
+
};
|
|
193
250
|
|
|
194
251
|
const debouncedHandler = debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
|
|
195
252
|
receiver.addEventListener(eventName, debouncedHandler);
|
|
253
|
+
if (!listeners.has(el)) listeners.set(el, []);
|
|
254
|
+
listeners.get(el).push({receiver, eventName, handler: debouncedHandler});
|
|
196
255
|
}
|
|
197
256
|
}
|
|
198
257
|
});
|
|
@@ -200,13 +259,15 @@ el.style = Object.entries(result).map(([key, value]) => value ? `${key}: ${value
|
|
|
200
259
|
return newBindings;
|
|
201
260
|
}
|
|
202
261
|
|
|
203
|
-
const observer = new MutationObserver(
|
|
262
|
+
const observer = new MutationObserver(mutations => {
|
|
204
263
|
if (isUpdatingDOM) return;
|
|
205
264
|
for (const m of mutations) {
|
|
265
|
+
for (const node of m.removedNodes) {
|
|
266
|
+
if (node.nodeType === 1) cleanup(node);
|
|
267
|
+
}
|
|
206
268
|
for (const node of m.addedNodes) {
|
|
207
269
|
if (node.nodeType === 1 && !node.hasAttribute("data-helium-processed")) {
|
|
208
|
-
|
|
209
|
-
newly.forEach(applyBinding);
|
|
270
|
+
processElements(node).forEach(applyBinding);
|
|
210
271
|
}
|
|
211
272
|
}
|
|
212
273
|
}
|