@daz4126/helium 0.9.0 → 0.10.1

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