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