@daz4126/helium 0.7.3 → 0.9.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/README.md CHANGED
@@ -14,10 +14,10 @@ It's really simple to use - just sprinkle the magic @attributes into your HTML a
14
14
 
15
15
  [See more examples here](https://codepen.io/daz4126/pen/YPwwdBK)
16
16
 
17
- To use, just import from the CDN (no install or build step required!):
17
+ To use, just import from the CDN in a script tag directly in your HTML page (no install or build step required!):
18
18
 
19
- ```javascript
20
- import helium from "https://cdn.jsdelivr.net/gh/daz-codes/helium/helium.js"
19
+ ```html
20
+ <script type="module">import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium.js';helium();</script>
21
21
  ```
22
22
 
23
23
  Alernatively you can install from NPM:
@@ -26,10 +26,11 @@ Alernatively you can install from NPM:
26
26
  npm install @daz4126/helium
27
27
  ```
28
28
 
29
- Then include it in your
29
+ Then include it in your JavaScript file and call the `helium()` function:
30
30
 
31
31
  ```javascript
32
32
  import helium from "@daz4126/helium"
33
+ helium()
33
34
  ```
34
35
 
35
36
  # Helium Attributes
package/helium.js CHANGED
@@ -1,80 +1,81 @@
1
- let ue = true;
2
1
  export default function helium(data = {}) {
3
- const root =
4
- document.querySelector("[\\@helium]") ||
5
- document.querySelector("[data-helium]") ||
6
- document.body;
7
- if (Object.keys(data).length > 0)
8
- document
9
- .querySelectorAll("[data-helium-processed]")
10
- .forEach((el) => el.removeAttribute("data-helium-processed"));
2
+ const root = document.querySelector("[\\@helium]") || document.querySelector("[data-helium]") || document.body;
11
3
  const [bindings, refs] = [new Map(), new Map()];
12
- const $ = (selector) => document.querySelector(selector);
13
- const html = (string) => {
14
- const temp = document.createElement("template");
15
- temp.innerHTML = string;
16
- return temp.content.firstChild;
4
+ const $ = (s) => document.querySelector(s);
5
+ const html = (s) => {
6
+ const t = document.createElement("template");
7
+ t.innerHTML = s;
8
+ return t.content.firstChild;
17
9
  };
18
10
 
19
- const ajax = (m, u, t, d) =>
20
- fetch(u, {
21
- method: m,
22
- headers: { "Content-Type": "application/json" },
23
- body: m == "GET" ? null : JSON.stringify(d),
11
+ const ajax = (url, method, target, opts = {}) => {
12
+ const headers = {
13
+ "Content-Type": "application/json",
14
+ "Accept": "text/vnd.turbo-stream.html, application/json, application/vnd.api+json, text/html, */*"
15
+ };
16
+
17
+ // Only add CSRF token if it exists
18
+ if (document.querySelector('meta[name="csrf-token"]')?.content) headers["X-CSRF-Token"] = csrfToken;
19
+
20
+ fetch(url, {
21
+ method,
22
+ headers,
23
+ body: method === "GET" ? null : JSON.stringify(opts),
24
+ credentials: "same-origin"
24
25
  })
25
- .then((r) =>
26
- r[r.headers.get("content-type")?.includes("json") ? "json" : "text"](),
26
+ .then(r =>
27
+ r.headers.get("content-type")?.includes("application/json") ? r.json() : r.text()
27
28
  )
28
- .then((x) => (state[t] = x));
29
-
30
- const [get, post, put, patch, del] = [
31
- "GET",
32
- "POST",
33
- "PUT",
34
- "PATCH",
35
- "DELETE",
36
- ].map((m) => (u, d, t) => ajax(m, u, t, d));
29
+ .then(d => state[target] = d)
30
+ .catch(e => console.error("AJAX error:", e.message));
31
+ };
37
32
 
38
- const state = new Proxy(data, {
39
- get(target, prop) {
40
- return target[prop];
33
+ 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));
35
+
36
+ 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;
41
40
  },
42
- set(target, prop, value) {
43
- // skip if identical (prevents redundant re-renders)
44
- if (target[prop] === value) return true;
45
- target[prop] = value;
46
- if (bindings.has(prop)) {
47
- // only call the bindings for this prop
48
- bindings.get(prop).forEach((binding) => applyBinding(binding));
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
+ }
49
51
  }
50
- return true;
51
- },
52
- });
52
+ return result;
53
+ }
54
+ };
53
55
 
56
+ const state = new Proxy(data, handler);
54
57
  let isUpdatingDOM = false;
55
58
 
56
59
  function applyBinding(binding, event = {}, elCtx = binding.el) {
57
60
  const { el, prop, fn } = binding;
58
- const result = fn(
59
- $,
60
- state,
61
- event,
62
- elCtx,
63
- html,
64
- get,
65
- post,
66
- put,
67
- patch,
68
- del,
69
- ...Object.values(data),
70
- ...[...refs.values()],
71
- );
72
- // guard to reduce observer recursion (optional but helpful)
73
- if (prop === "innerHTML") {
61
+ const result = fn($, state, event, elCtx, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
62
+ if (prop == "innerHTML") {
74
63
  isUpdatingDOM = true;
75
64
  el.innerHTML = result;
76
65
  isUpdatingDOM = false;
77
- } else if (prop in el) {
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) {
78
79
  el[prop] = result;
79
80
  } else {
80
81
  el.setAttribute(prop, result);
@@ -83,286 +84,132 @@ export default function helium(data = {}) {
83
84
 
84
85
  function compileExpression(expr, withReturn = false) {
85
86
  try {
86
- return new Function(
87
- "$",
88
- "$data",
89
- "$event",
90
- "$el",
91
- "$html",
92
- "$get",
93
- "$post",
94
- "$put",
95
- "$patch",
96
- "$delete",
97
- ...Object.keys(data),
98
- ...[...refs.keys()],
99
- `with($data) { ${withReturn ? "return" : ""} (${expr.trim()}) }`,
100
- );
87
+ return new Function("$", "$data", "$event", "$el", "$html", "$get", "$post", "$put", "$patch", "$delete",
88
+ ...Object.keys(data), ...[...refs.keys()],
89
+ `with($data) { ${withReturn ? "return" : ""} (${expr.trim()}) }`);
101
90
  } catch (err) {
102
91
  return () => expr;
103
92
  }
104
93
  }
105
94
 
106
- // processElements now returns an array of newly-created binding objects
107
- function processElements(element, addEvents = ue) {
108
- const newlyAddedBindings = [];
95
+ function processElements(element) {
96
+ 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", "");
109
99
 
110
- // quick guard: avoid re-processing same node
111
- if (
112
- element.nodeType === 1 &&
113
- element.hasAttribute &&
114
- element.hasAttribute("data-helium-processed")
115
- )
116
- return newlyAddedBindings;
117
- if (element.nodeType === 1 && element.setAttribute)
118
- element.setAttribute("data-helium-processed", "");
100
+ const heElements = [element, ...Array.from(element.querySelectorAll("*")).filter((el) =>
101
+ Array.from(el.attributes).some((attr) => attr.name.startsWith("data-he-")))];
119
102
 
120
- const heliumElements = [
121
- element,
122
- ...Array.from(element.querySelectorAll("*")).filter((el) =>
123
- Array.from(el.attributes).some((attr) =>
124
- attr.name.startsWith("data-he-"),
125
- ),
126
- ),
127
- ];
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));
128
106
 
129
- const xpath = document.evaluate(
130
- ".//*[@*[starts-with(name(), '@') or starts-with(name(), ':')]]",
131
- element,
132
- null,
133
- XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
134
- null,
135
- );
136
- for (let i = 0; i < xpath.snapshotLength; i++)
137
- heliumElements.push(xpath.snapshotItem(i));
138
-
139
- // seed default state values (no binding creation here)
140
- heliumElements.forEach((el) => {
107
+ // Seed default state values
108
+ heElements.forEach((el) => {
141
109
  for (const { name, value } of el.attributes || []) {
142
- if (
143
- name == "@html" ||
144
- name == "data-he-html" ||
145
- name == "@text" ||
146
- name == "data-he-text" ||
147
- name == "@bind" ||
148
- name == "data-he-bind"
149
- ) {
110
+ if (["@html","data-he-html","@text","data-he-text","@bind","data-he-bind"].includes(name)) {
150
111
  try {
151
112
  new Function(`let ${value} = 1`);
152
- state[value] ||=
153
- name == "@bind" || name == "data-he-bind"
154
- ? el.value
155
- : el.textContent;
113
+ state[value] ||= ["@bind","data-he-bind"].includes(name) ? el.value : el.textContent;
156
114
  } catch (e) {}
157
115
  }
158
116
  }
159
117
  });
160
118
 
161
- // Register bindings for these elements and collect the newly added binding objects
162
- heliumElements.forEach((el) => {
163
- for (const { name, value } of el.attributes || []) {
164
- if (name == "@data" || name == "data-he") {
165
- Object.assign(
166
- state,
167
- compileExpression(value, true)(
168
- $,
169
- state,
170
- {},
171
- el,
172
- html,
173
- get,
174
- post,
175
- put,
176
- patch,
177
- del,
178
- ...Object.values(data),
179
- ...[...refs.values()],
180
- ),
181
- );
182
- }
183
- if (name == "@ref" || name == "data-he-ref") refs.set("$" + value, el);
184
119
 
185
- if (
186
- name == "@text" ||
187
- name == "data-he-text" ||
188
- name == "@html" ||
189
- name == "data-he-html"
190
- ) {
191
- Object.keys(state)
192
- .filter((key) => value.includes(key))
193
- .forEach((val) => {
194
- const b = {
195
- el,
196
- prop:
197
- name == "@text" || name == "data-he-text"
198
- ? "textContent"
199
- : "innerHTML",
200
- expr: value,
201
- fn: compileExpression(value, true),
202
- };
203
- bindings.set(val, [...(bindings.get(val) || []), b]);
204
- newlyAddedBindings.push(b);
205
- });
120
+ // 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()]);
123
+ for (const { name, value } of el.attributes || []) {
124
+ 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
+ });
206
133
  }
207
134
 
208
- if (name == "@bind" || name == "data-he-bind") {
135
+ if (["@bind","data-he-bind"].includes(name)) {
209
136
  el.addEventListener("input", (e) => (state[value] = e.target.value));
210
- const b = {
211
- el,
212
- prop: "value",
213
- expr: value,
214
- fn: compileExpression(value, true),
215
- };
137
+ const b = { el, prop: "value", expr: value, fn: compileExpression(value, true) };
216
138
  bindings.set(value, [...(bindings.get(value) || []), b]);
217
- newlyAddedBindings.push(b);
139
+ newBindings.push(b);
218
140
  el.value = state[value];
219
141
  }
220
142
 
221
- if (
222
- name == "@hidden" ||
223
- name == "@visible" ||
224
- name == "data-he-hidden" ||
225
- name == "data-he-visible"
226
- ) {
227
- Object.keys(state)
228
- .filter((key) => value.includes(key))
229
- .forEach((val) => {
230
- const b = {
231
- el,
232
- prop: "hidden",
233
- expr: value,
234
- fn: compileExpression(
235
- `${name == "@hidden" || name == "data-he-hidden" ? "!" : ""}!(${value})`,
236
- true,
237
- ),
238
- };
239
- bindings.set(val, [...(bindings.get(val) || []), b]);
240
- newlyAddedBindings.push(b);
241
- });
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
+ });
242
149
  }
243
150
 
244
151
  if (name.startsWith(":")) {
245
- Object.keys(state)
246
- .filter((key) => value.includes(key))
247
- .forEach((val) => {
248
- const b = {
249
- el,
250
- prop: name.split(":")[1],
251
- expr: value,
252
- fn: compileExpression(value, true),
253
- };
254
- bindings.set(val, [...(bindings.get(val) || []), b]);
255
- newlyAddedBindings.push(b);
256
- });
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
+ });
257
157
  }
258
158
 
259
- if (name == "@init" || name == "data-he-init") {
260
- compileExpression(value, false)(
261
- $,
262
- state,
263
- undefined,
264
- el,
265
- html,
266
- get,
267
- post,
268
- put,
269
- patch,
270
- del,
271
- ...Object.values(data),
272
- ...[...refs.values()],
273
- );
274
- } else if (
275
- (name.startsWith("@") || name.startsWith("data-he-on")) &&
276
- addEvents
277
- ) {
278
- // event handling — keep this as before (register listeners)
279
- const [eventName, ...modifiers] = name
280
- .slice(name.startsWith("@") ? 1 : 10)
281
- .split(".");
282
- const receiver =
283
- modifiers.includes("outside") || modifiers.includes("document")
284
- ? document
285
- : el;
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()]);
161
+ } else if (name.startsWith("@") || name.startsWith("data-he-on")) {
162
+ const [eventName, ...mods] = name.slice(name.startsWith("@") ? 1 : 10).split(".");
163
+ const receiver = mods.includes("outside") || mods.includes("document") ? document : el;
286
164
  const debounce = (fn, delay) => {
287
165
  let timeout;
288
- return function (...args) {
166
+ return (...args) => {
289
167
  clearTimeout(timeout);
290
- timeout = setTimeout(() => fn.apply(this, args), delay);
168
+ timeout = setTimeout(() => fn(...args), delay);
291
169
  };
292
170
  };
293
171
 
294
172
  let debounceDelay = 0;
295
- const debounceModifier = modifiers.find((m) =>
296
- m.startsWith("debounce"),
297
- );
298
- if (debounceModifier) {
299
- const time = debounceModifier.split(":")[1];
300
- if (time && !isNaN(time)) debounceDelay = Number(time);
301
- else debounceDelay = 300;
173
+ const debounceMod = mods.find((m) => m.startsWith("debounce"));
174
+ if (debounceMod) {
175
+ const time = debounceMod.split(":")[1];
176
+ debounceDelay = time && !isNaN(time) ? Number(time) : 300;
302
177
  }
303
178
 
304
179
  function _handler(e) {
305
- if (modifiers.includes("prevent")) e.preventDefault();
306
- const keyModifiers = {
307
- shift: "shiftKey",
308
- ctrl: "ctrlKey",
309
- alt: "altKey",
310
- meta: "metaKey",
311
- };
312
- for (const [mod, prop] of Object.entries(keyModifiers)) {
313
- if (modifiers.includes(mod) && !e[prop]) return;
180
+ if (mods.includes("prevent")) e.preventDefault();
181
+ const keyMods = { shift: "shiftKey", ctrl: "ctrlKey", alt: "altKey", meta: "metaKey" };
182
+ for (const [mod, prop] of Object.entries(keyMods)) {
183
+ if (mods.includes(mod) && !e[prop]) return;
314
184
  }
315
- if (["keydown", "keyup", "keypress"].includes(eventName)) {
316
- const last = modifiers[modifiers.length - 1];
185
+ if (["keydown","keyup","keypress"].includes(eventName)) {
186
+ const last = mods[mods.length - 1];
317
187
  if (last) {
318
- let keyName = e.key;
319
- if (keyName === " ") keyName = "Space";
320
- else if (keyName === "Escape") keyName = "Esc";
188
+ let keyName = e.key === " " ? "Space" : e.key === "Escape" ? "Esc" : e.key;
321
189
  if (keyName.toLowerCase() !== last.toLowerCase()) return;
322
190
  }
323
191
  }
324
-
325
- if (!modifiers.includes("outside") || !el.contains(e.target)) {
326
- compileExpression(value, false)(
327
- $,
328
- state,
329
- e,
330
- el,
331
- html,
332
- get,
333
- post,
334
- put,
335
- patch,
336
- del,
337
- ...Object.values(data),
338
- ...[...refs.values()],
339
- );
192
+ 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()]);
340
194
  }
341
- if (modifiers.includes("once"))
342
- el.removeEventListener(eventName, debouncedHandler);
195
+ if (mods.includes("once")) el.removeEventListener(eventName, debouncedHandler);
343
196
  }
344
197
 
345
- const debouncedHandler =
346
- debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
198
+ const debouncedHandler = debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
347
199
  receiver.addEventListener(eventName, debouncedHandler);
348
200
  }
349
- } // end attributes loop
350
- }); // end heliumElements.forEach
201
+ }
202
+ });
351
203
 
352
- return newlyAddedBindings;
353
- } // end processElements
204
+ return newBindings;
205
+ }
354
206
 
355
- // set up mutation observer that only processes and applies bindings for newly added nodes
356
207
  const observer = new MutationObserver((mutations) => {
357
- if (isUpdatingDOM) return; // ignore mutations we caused directly
208
+ if (isUpdatingDOM) return;
358
209
  for (const m of mutations) {
359
210
  for (const node of m.addedNodes) {
360
- if (
361
- node.nodeType === 1 &&
362
- !node.hasAttribute("data-helium-processed")
363
- ) {
364
- const newly = processElements(node, false);
365
- // apply only the bindings we just created for this node
211
+ if (node.nodeType === 1 && !node.hasAttribute("data-helium-processed")) {
212
+ const newly = processElements(node);
366
213
  newly.forEach(applyBinding);
367
214
  }
368
215
  }
@@ -370,9 +217,6 @@ export default function helium(data = {}) {
370
217
  });
371
218
  observer.observe(root, { childList: true, subtree: true });
372
219
 
373
- const initialBindings = processElements(root);
220
+ processElements(root);
374
221
  for (const [key, items] of bindings.entries()) items.forEach(applyBinding);
375
- ue = false;
376
222
  }
377
-
378
- helium();
package/helium.test.js ADDED
@@ -0,0 +1,441 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import helium from './helium.js';
3
+
4
+ describe('Helium Reactive Library', () => {
5
+ let container;
6
+
7
+ beforeEach(() => {
8
+ container = document.createElement('div');
9
+ container.setAttribute('data-helium', '');
10
+ document.body.appendChild(container);
11
+ });
12
+
13
+ afterEach(() => {
14
+ document.body.innerHTML = '';
15
+ });
16
+
17
+ describe('State Reactivity', () => {
18
+ it('should initialize with default data', () => {
19
+ container.innerHTML = '<div @text="count"></div>';
20
+ helium({ count: 5 });
21
+ expect(container.querySelector('div').textContent).toBe('5');
22
+ });
23
+
24
+ it('should react to direct property assignment', (done) => {
25
+ container.innerHTML = '<div @text="count"></div>';
26
+ const state = helium({ count: 0 });
27
+
28
+ setTimeout(() => {
29
+ state.count = 10;
30
+ setTimeout(() => {
31
+ expect(container.querySelector('div').textContent).toBe('10');
32
+ done();
33
+ }, 0);
34
+ }, 0);
35
+ });
36
+
37
+ it('should react to nested object mutations', (done) => {
38
+ container.innerHTML = '<div @text="user.name"></div>';
39
+ const state = helium({ user: { name: 'Alice' } });
40
+
41
+ setTimeout(() => {
42
+ state.user.name = 'Bob';
43
+ setTimeout(() => {
44
+ expect(container.querySelector('div').textContent).toBe('Bob');
45
+ done();
46
+ }, 0);
47
+ }, 0);
48
+ });
49
+
50
+ it('should react to array push', (done) => {
51
+ container.innerHTML = '<div @text="items.length"></div>';
52
+ const state = helium({ items: [1, 2, 3] });
53
+
54
+ setTimeout(() => {
55
+ state.items.push(4);
56
+ setTimeout(() => {
57
+ expect(container.querySelector('div').textContent).toBe('4');
58
+ done();
59
+ }, 0);
60
+ }, 0);
61
+ });
62
+
63
+ it('should react to array index assignment', (done) => {
64
+ container.innerHTML = '<div @text="items[0]"></div>';
65
+ const state = helium({ items: ['a', 'b', 'c'] });
66
+
67
+ setTimeout(() => {
68
+ state.items[0] = 'z';
69
+ setTimeout(() => {
70
+ expect(container.querySelector('div').textContent).toBe('z');
71
+ done();
72
+ }, 0);
73
+ }, 0);
74
+ });
75
+
76
+ it('should react to complete array replacement', (done) => {
77
+ container.innerHTML = '<div @text="items.length"></div>';
78
+ const state = helium({ items: [1, 2, 3] });
79
+
80
+ setTimeout(() => {
81
+ state.items = [...state.items, 4, 5];
82
+ setTimeout(() => {
83
+ expect(container.querySelector('div').textContent).toBe('5');
84
+ done();
85
+ }, 0);
86
+ }, 0);
87
+ });
88
+ });
89
+
90
+ describe('Directives - @text and @html', () => {
91
+ it('should bind text content with @text', () => {
92
+ container.innerHTML = '<div @text="message"></div>';
93
+ helium({ message: 'Hello World' });
94
+ expect(container.querySelector('div').textContent).toBe('Hello World');
95
+ });
96
+
97
+ it('should bind HTML content with @html', () => {
98
+ container.innerHTML = '<div @html="content"></div>';
99
+ helium({ content: '<strong>Bold</strong>' });
100
+ expect(container.querySelector('div').innerHTML).toBe('<strong>Bold</strong>');
101
+ });
102
+
103
+ it('should work with data-he-text prefix', () => {
104
+ container.innerHTML = '<div data-he-text="message"></div>';
105
+ helium({ message: 'Test' });
106
+ expect(container.querySelector('div').textContent).toBe('Test');
107
+ });
108
+
109
+ it('should evaluate expressions in @text', () => {
110
+ container.innerHTML = '<div @text="count * 2"></div>';
111
+ helium({ count: 5 });
112
+ expect(container.querySelector('div').textContent).toBe('10');
113
+ });
114
+ });
115
+
116
+ describe('Directives - @bind', () => {
117
+ it('should create two-way binding with input', () => {
118
+ container.innerHTML = '<input @bind="name" />';
119
+ const state = helium({ name: 'John' });
120
+ const input = container.querySelector('input');
121
+
122
+ expect(input.value).toBe('John');
123
+ });
124
+
125
+ it('should update state when input changes', (done) => {
126
+ container.innerHTML = '<input @bind="name" /><span @text="name"></span>';
127
+ const state = helium({ name: 'John' });
128
+ const input = container.querySelector('input');
129
+
130
+ input.value = 'Jane';
131
+ input.dispatchEvent(new Event('input'));
132
+
133
+ setTimeout(() => {
134
+ expect(container.querySelector('span').textContent).toBe('Jane');
135
+ done();
136
+ }, 0);
137
+ });
138
+ });
139
+
140
+ describe('Directives - @hidden and @visible', () => {
141
+ it('should hide element with @hidden when true', () => {
142
+ container.innerHTML = '<div @hidden="isHidden"></div>';
143
+ helium({ isHidden: true });
144
+ expect(container.querySelector('div').hidden).toBe(true);
145
+ });
146
+
147
+ it('should show element with @hidden when false', () => {
148
+ container.innerHTML = '<div @hidden="isHidden"></div>';
149
+ helium({ isHidden: false });
150
+ expect(container.querySelector('div').hidden).toBe(false);
151
+ });
152
+
153
+ it('should show element with @visible when true', () => {
154
+ container.innerHTML = '<div @visible="isVisible"></div>';
155
+ helium({ isVisible: true });
156
+ expect(container.querySelector('div').hidden).toBe(false);
157
+ });
158
+
159
+ it('should hide element with @visible when false', () => {
160
+ container.innerHTML = '<div @visible="isVisible"></div>';
161
+ helium({ isVisible: false });
162
+ expect(container.querySelector('div').hidden).toBe(true);
163
+ });
164
+ });
165
+
166
+ describe('Directives - Dynamic Attributes (:attr)', () => {
167
+ it('should bind attribute dynamically', () => {
168
+ container.innerHTML = '<div :class="className"></div>';
169
+ helium({ className: 'active' });
170
+ expect(container.querySelector('div').getAttribute('class')).toBe('active');
171
+ });
172
+
173
+ it('should update attribute on state change', (done) => {
174
+ container.innerHTML = '<div :title="tooltip"></div>';
175
+ const state = helium({ tooltip: 'Initial' });
176
+
177
+ setTimeout(() => {
178
+ state.tooltip = 'Updated';
179
+ setTimeout(() => {
180
+ expect(container.querySelector('div').getAttribute('title')).toBe('Updated');
181
+ done();
182
+ }, 0);
183
+ }, 0);
184
+ });
185
+ });
186
+
187
+ describe('Event Handlers', () => {
188
+ it('should handle click events', () => {
189
+ container.innerHTML = '<button @click="count++">Click</button><span @text="count"></span>';
190
+ helium({ count: 0 });
191
+
192
+ container.querySelector('button').click();
193
+ setTimeout(() => {
194
+ expect(container.querySelector('span').textContent).toBe('1');
195
+ }, 0);
196
+ });
197
+
198
+ it('should handle event with prevent modifier', () => {
199
+ container.innerHTML = '<form @submit.prevent="submitted = true"></form>';
200
+ const state = helium({ submitted: false });
201
+ const form = container.querySelector('form');
202
+ const event = new Event('submit', { cancelable: true });
203
+
204
+ form.dispatchEvent(event);
205
+ expect(event.defaultPrevented).toBe(true);
206
+ });
207
+
208
+ it('should handle keyboard events with key modifiers', () => {
209
+ container.innerHTML = '<input @keydown.enter="pressed = true" />';
210
+ const state = helium({ pressed: false });
211
+ const input = container.querySelector('input');
212
+
213
+ const event = new KeyboardEvent('keydown', { key: 'Enter' });
214
+ input.dispatchEvent(event);
215
+
216
+ setTimeout(() => {
217
+ expect(state.pressed).toBe(true);
218
+ }, 0);
219
+ });
220
+
221
+ it('should handle debounced events', (done) => {
222
+ container.innerHTML = '<input @input.debounce:100="value = $event.target.value" />';
223
+ const state = helium({ value: '' });
224
+ const input = container.querySelector('input');
225
+
226
+ input.value = 'test';
227
+ input.dispatchEvent(new Event('input'));
228
+
229
+ // Should not update immediately
230
+ expect(state.value).toBe('');
231
+
232
+ setTimeout(() => {
233
+ expect(state.value).toBe('test');
234
+ done();
235
+ }, 150);
236
+ });
237
+
238
+ it('should handle once modifier', () => {
239
+ container.innerHTML = '<button @click.once="count++">Click</button>';
240
+ const state = helium({ count: 0 });
241
+ const button = container.querySelector('button');
242
+
243
+ button.click();
244
+ button.click();
245
+ button.click();
246
+
247
+ setTimeout(() => {
248
+ expect(state.count).toBe(1);
249
+ }, 0);
250
+ });
251
+ });
252
+
253
+ describe('@ref Directive', () => {
254
+ it('should create reference to element', () => {
255
+ container.innerHTML = '<div @ref="myDiv"></div><button @click="$myDiv.textContent = \'Changed\'">Change</button>';
256
+ helium({});
257
+
258
+ container.querySelector('button').click();
259
+ setTimeout(() => {
260
+ expect(container.querySelector('div').textContent).toBe('Changed');
261
+ }, 0);
262
+ });
263
+ });
264
+
265
+ describe('@init Directive', () => {
266
+ it('should execute code on initialization', () => {
267
+ container.innerHTML = '<div @init="initialized = true"></div>';
268
+ const state = helium({ initialized: false });
269
+ expect(state.initialized).toBe(true);
270
+ });
271
+ });
272
+
273
+ describe('@data Directive', () => {
274
+ it('should merge data into state', () => {
275
+ container.innerHTML = '<div @data="{ user: \'Alice\', age: 30 }"></div><span @text="user"></span>';
276
+ helium({});
277
+ expect(container.querySelector('span').textContent).toBe('Alice');
278
+ });
279
+ });
280
+
281
+ describe('Helper Functions', () => {
282
+ it('should provide $ selector helper', () => {
283
+ container.innerHTML = '<div id="target"></div><button @click="$(\'#target\').textContent = \'Found\'">Click</button>';
284
+ helium({});
285
+
286
+ container.querySelector('button').click();
287
+ setTimeout(() => {
288
+ expect(container.querySelector('#target').textContent).toBe('Found');
289
+ }, 0);
290
+ });
291
+
292
+ it('should provide $html helper to create elements', () => {
293
+ container.innerHTML = '<div @init="$el.appendChild($html(\'<span>Test</span>\'))"></div>';
294
+ helium({});
295
+ expect(container.querySelector('span').textContent).toBe('Test');
296
+ });
297
+ });
298
+
299
+ describe('AJAX Helpers', () => {
300
+ beforeEach(() => {
301
+ global.fetch = vi.fn();
302
+ });
303
+
304
+ it('should call GET request with $get', async () => {
305
+ global.fetch.mockResolvedValueOnce({
306
+ headers: { get: () => 'application/json' },
307
+ json: async () => ({ data: 'test' })
308
+ });
309
+
310
+ container.innerHTML = '<button @click="$get(\'/api/data\', \'result\')">Load</button><div @text="result?.data"></div>';
311
+ helium({ result: null });
312
+
313
+ container.querySelector('button').click();
314
+
315
+ await new Promise(resolve => setTimeout(resolve, 100));
316
+ expect(global.fetch).toHaveBeenCalledWith('/api/data', expect.any(Object));
317
+ });
318
+
319
+ it('should call POST request with $post', async () => {
320
+ global.fetch.mockResolvedValueOnce({
321
+ headers: { get: () => 'application/json' },
322
+ json: async () => ({ success: true })
323
+ });
324
+
325
+ container.innerHTML = '<button @click="$post(\'/api/save\', { name: \'test\' }, \'response\')">Save</button>';
326
+ helium({ response: null });
327
+
328
+ container.querySelector('button').click();
329
+
330
+ await new Promise(resolve => setTimeout(resolve, 100));
331
+ expect(global.fetch).toHaveBeenCalledWith('/api/save', expect.objectContaining({
332
+ method: 'POST',
333
+ body: JSON.stringify({ name: 'test' })
334
+ }));
335
+ });
336
+ });
337
+
338
+ describe('Dynamic DOM Updates', () => {
339
+ it('should process dynamically added elements', (done) => {
340
+ container.innerHTML = '<div id="container"></div>';
341
+ const state = helium({ message: 'Dynamic' });
342
+
343
+ setTimeout(() => {
344
+ const newEl = document.createElement('span');
345
+ newEl.setAttribute('@text', 'message');
346
+ container.querySelector('#container').appendChild(newEl);
347
+
348
+ setTimeout(() => {
349
+ expect(newEl.textContent).toBe('Dynamic');
350
+ done();
351
+ }, 100);
352
+ }, 0);
353
+ });
354
+
355
+ it('should handle innerHTML updates with new bindings', (done) => {
356
+ container.innerHTML = '<div @html="content"></div>';
357
+ const state = helium({ content: '', name: 'Test' });
358
+
359
+ setTimeout(() => {
360
+ state.content = '<span @text="name"></span>';
361
+ setTimeout(() => {
362
+ expect(container.querySelector('span').textContent).toBe('Test');
363
+ done();
364
+ }, 100);
365
+ }, 0);
366
+ });
367
+ });
368
+
369
+ describe('Edge Cases', () => {
370
+ it('should handle undefined state values', () => {
371
+ container.innerHTML = '<div @text="undefined"></div>';
372
+ helium({});
373
+ expect(container.querySelector('div').textContent).toBe('undefined');
374
+ });
375
+
376
+ it('should handle null state values', () => {
377
+ container.innerHTML = '<div @text="value"></div>';
378
+ helium({ value: null });
379
+ expect(container.querySelector('div').textContent).toBe('null');
380
+ });
381
+
382
+ it('should handle circular references in state', () => {
383
+ const obj = { name: 'test' };
384
+ obj.self = obj;
385
+
386
+ container.innerHTML = '<div @text="obj.name"></div>';
387
+ expect(() => helium({ obj })).not.toThrow();
388
+ });
389
+
390
+ it('should not process already processed elements', () => {
391
+ container.innerHTML = '<div @text="count"></div>';
392
+ const state = helium({ count: 0 });
393
+
394
+ // Manually mark as processed
395
+ container.querySelector('div').setAttribute('data-helium-processed', '');
396
+
397
+ state.count = 5;
398
+ // Should still work because binding was already created
399
+ setTimeout(() => {
400
+ expect(container.querySelector('div').textContent).toBe('5');
401
+ }, 0);
402
+ });
403
+ });
404
+
405
+ describe('Performance', () => {
406
+ it('should handle many bindings efficiently', () => {
407
+ const html = Array.from({ length: 100 }, (_, i) =>
408
+ `<div @text="items[${i}]"></div>`
409
+ ).join('');
410
+
411
+ container.innerHTML = html;
412
+ const items = Array.from({ length: 100 }, (_, i) => i);
413
+
414
+ const start = performance.now();
415
+ helium({ items });
416
+ const end = performance.now();
417
+
418
+ expect(end - start).toBeLessThan(100); // Should initialize in < 100ms
419
+ });
420
+
421
+ it('should not cause infinite loops with DOM mutations', (done) => {
422
+ container.innerHTML = '<div @html="content"></div>';
423
+ const state = helium({ content: '<span>Test</span>', updateCount: 0 });
424
+
425
+ let mutationCount = 0;
426
+ const observer = new MutationObserver(() => {
427
+ mutationCount++;
428
+ });
429
+ observer.observe(container, { childList: true, subtree: true });
430
+
431
+ setTimeout(() => {
432
+ state.content = '<span>Updated</span>';
433
+ setTimeout(() => {
434
+ expect(mutationCount).toBeLessThan(10); // Should not cause excessive mutations
435
+ observer.disconnect();
436
+ done();
437
+ }, 100);
438
+ }, 0);
439
+ });
440
+ });
441
+ });
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@daz4126/helium",
3
- "version": "0.7.3",
3
+ "version": "0.9.0",
4
4
  "main": "helium.js",
5
5
  "type": "module",
6
+ "scripts": {
7
+ "test": "vitest",
8
+ "test:ui": "vitest --ui",
9
+ "test:run": "vitest run"
10
+ },
6
11
  "keywords": [
7
12
  "tiny",
8
13
  "reactive",
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'jsdom',
6
+ globals: true,
7
+ },
8
+ });