@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.
Files changed (2) hide show
  1. package/helium.js +134 -73
  2. 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((m) => (u, d, t) => ajax(u, m, t, d));
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
- return typeof val === "object" && val !== null ? new Proxy(val, handler) : val;
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(result) == "object"){
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(result) == "object"){
73
- el.style = Object.entries(result).map(([key, value]) => value ? `${key}: ${value};` : "").join``
74
- } else if (prop in el) {
75
- el[prop] = result;
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
- function compileExpression(expr, withReturn = false) {
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) { ${withReturn ? "return" : ""} (${expr.trim()}) }`);
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 && element.hasAttribute?.("data-helium-processed")) return newBindings;
94
- if (element.nodeType === 1 && element.setAttribute) element.setAttribute("data-helium-processed", "");
95
-
96
- const heElements = [element, ...Array.from(element.querySelectorAll("*")).filter((el) =>
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 xpath = document.evaluate(".//*[@*[starts-with(name(), '@') or starts-with(name(), ':')]]",
100
- element, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
101
- for (let i = 0; i < xpath.snapshotLength; i++) heElements.push(xpath.snapshotItem(i));
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((el) => {
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} = 1`);
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((el) => {
118
- const execFn = (v) => compileExpression(v, true)($, state, {}, el, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
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
- Object.keys(state).filter((k) => value.includes(k)).forEach((val) => {
125
- const b = { el, prop: ["@text","data-he-text"].includes(name) ? "textContent" : "innerHTML", expr: value, fn: compileExpression(value, true) };
126
- bindings.set(val, [...(bindings.get(val) || []), b]);
127
- newBindings.push(b);
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
- el.addEventListener("input", (e) => (state[value] = e.target.value));
133
- const b = { el, prop: "value", expr: value, fn: compileExpression(value, true) };
134
- bindings.set(value, [...(bindings.get(value) || []), b]);
135
- newBindings.push(b);
136
- el.value = state[value];
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
- Object.keys(state).filter((k) => value.includes(k)).forEach((val) => {
141
- const b = { el, prop: "hidden", expr: value, fn: compileExpression(`${["@hidden","data-he-hidden"].includes(name) ? "!" : ""}!(${value})`, true) };
142
- bindings.set(val, [...(bindings.get(val) || []), b]);
143
- newBindings.push(b);
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
- Object.keys(state).filter((k) => value.includes(k)).forEach((val) => {
149
- const b = { el, prop: name.split(":")[1], expr: value, fn: compileExpression(value, true) };
150
- bindings.set(val, [...(bindings.get(val) || []), b]);
151
- newBindings.push(b);
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
- compileExpression(value, false)($, state, undefined, el, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
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
- const debounce = (fn, delay) => {
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((m) => m.startsWith("debounce"));
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
- function _handler(e) {
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
- compileExpression(value, false)($, state, e, el, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
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((mutations) => {
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
- const newly = processElements(node);
209
- newly.forEach(applyBinding);
270
+ processElements(node).forEach(applyBinding);
210
271
  }
211
272
  }
212
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/helium",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "main": "helium.js",
5
5
  "type": "module",
6
6
  "scripts": {