@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.
Files changed (2) hide show
  1. package/helium.js +128 -71
  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");
@@ -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, application/json, application/vnd.api+json, text/html, */*"
14
+ "Accept": "text/vnd.turbo-stream.html,application/json,text/html"
15
15
  };
16
-
17
- // Only add CSRF token if it exists
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((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));
35
32
 
36
33
  const handler = {
37
34
  get(target, prop, receiver) {
38
35
  const val = Reflect.get(target, prop, receiver);
39
- 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;
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(result) == "object"){
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(result) == "object"){
77
- el.style = Object.entries(result).map(([key, value]) => value ? `${key}: ${value};` : "").join``
78
- } else if (prop in el) {
79
- 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;
80
79
  } else {
81
80
  el.setAttribute(prop, result);
82
81
  }
83
82
  }
84
83
 
85
- function compileExpression(expr, withReturn = false) {
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) { ${withReturn ? "return" : ""} (${expr.trim()}) }`);
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 && element.hasAttribute?.("data-helium-processed")) return newBindings;
98
- if (element.nodeType === 1 && element.setAttribute) element.setAttribute("data-helium-processed", "");
99
-
100
- const heElements = [element, ...Array.from(element.querySelectorAll("*")).filter((el) =>
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 xpath = document.evaluate(".//*[@*[starts-with(name(), '@') or starts-with(name(), ':')]]",
104
- element, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
105
- 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
+ );
106
143
 
107
144
  // Seed default state values
108
- heElements.forEach((el) => {
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} = 1`);
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((el) => {
122
- 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()]);
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
- Object.keys(state).filter((k) => value.includes(k)).forEach((val) => {
129
- const b = { el, prop: ["@text","data-he-text"].includes(name) ? "textContent" : "innerHTML", expr: value, fn: compileExpression(value, true) };
130
- bindings.set(val, [...(bindings.get(val) || []), b]);
131
- newBindings.push(b);
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
- el.addEventListener("input", (e) => (state[value] = e.target.value));
137
- const b = { el, prop: "value", expr: value, fn: compileExpression(value, true) };
138
- bindings.set(value, [...(bindings.get(value) || []), b]);
139
- newBindings.push(b);
140
- 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] ?? "";
141
193
  }
142
194
 
143
195
  if (["@hidden","@visible","data-he-hidden","data-he-visible"].includes(name)) {
144
- Object.keys(state).filter((k) => value.includes(k)).forEach((val) => {
145
- const b = { el, prop: "hidden", expr: value, fn: compileExpression(`${["@hidden","data-he-hidden"].includes(name) ? "!" : ""}!(${value})`, true) };
146
- bindings.set(val, [...(bindings.get(val) || []), b]);
147
- newBindings.push(b);
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
- Object.keys(state).filter((k) => value.includes(k)).forEach((val) => {
153
- const b = { el, prop: name.split(":")[1], expr: value, fn: compileExpression(value, true) };
154
- bindings.set(val, [...(bindings.get(val) || []), b]);
155
- newBindings.push(b);
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
- 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()]);
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
- const debounce = (fn, delay) => {
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((m) => m.startsWith("debounce"));
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
- function _handler(e) {
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
- 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()]);
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((mutations) => {
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
- const newly = processElements(node);
213
- newly.forEach(applyBinding);
270
+ processElements(node).forEach(applyBinding);
214
271
  }
215
272
  }
216
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/helium",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "main": "helium.js",
5
5
  "type": "module",
6
6
  "scripts": {