@daz4126/helium 0.7.2 → 0.8.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,82 +1,77 @@
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 = (method, url, target, data) =>
11
+ const ajax = (url, method, target, opts = {}) => {
20
12
  fetch(url, {
21
13
  method,
22
- headers: { "Content-Type": "application/json" },
23
- body: method === "GET" ? null : JSON.stringify(data),
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
+ },
19
+ body: method === "GET" ? null : JSON.stringify(opts),
20
+ credentials: "same-origin"
24
21
  })
25
- .then((res) =>
26
- res[
27
- res.headers.get("content-type")?.includes("json") ? "json" : "text"
28
- ](),
22
+ .then(r =>
23
+ r.headers.get("content-type")?.includes("application/json") ? r.json() : r.text()
29
24
  )
30
- .then((result) => (state[target] = result));
31
-
32
- const ax = (method) => (url, data, target) => ajax(method, url, target, data);
33
-
34
- const get = ax("GET");
35
- const post = ax("POST");
36
- const put = ax("PUT");
37
- const patch = ax("PATCH");
38
- const del = ax("DELETE");
25
+ .then(d => state[target] = d)
26
+ .catch(e => console.error("AJAX error:", e.message));
27
+ };
39
28
 
40
- const state = new Proxy(data, {
41
- get(target, prop) {
42
- return target[prop];
29
+ 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
+
32
+ const handler = {
33
+ get(target, prop, receiver) {
34
+ const val = Reflect.get(target, prop, receiver);
35
+ return typeof val === "object" && val !== null ? new Proxy(val, handler) : val;
43
36
  },
44
- set(target, prop, value) {
45
- // skip if identical (prevents redundant re-renders)
46
- if (target[prop] === value) return true;
47
- target[prop] = value;
48
- if (bindings.has(prop)) {
49
- // only call the bindings for this prop
50
- bindings.get(prop).forEach((binding) => applyBinding(binding));
37
+ set(target, prop, val, receiver) {
38
+ const result = Reflect.set(target, prop, val, receiver);
39
+ if (target === data && bindings.has(prop)) {
40
+ bindings.get(prop).forEach(applyBinding);
41
+ } else {
42
+ for (const [key, bound] of bindings.entries()) {
43
+ if (key === Object.keys(data).find(k => data[k] === target)) {
44
+ bound.forEach(applyBinding);
45
+ }
46
+ }
51
47
  }
52
- return true;
53
- },
54
- });
48
+ return result;
49
+ }
50
+ };
55
51
 
52
+ const state = new Proxy(data, handler);
56
53
  let isUpdatingDOM = false;
57
54
 
58
55
  function applyBinding(binding, event = {}, elCtx = binding.el) {
59
56
  const { el, prop, fn } = binding;
60
- const result = fn(
61
- $,
62
- state,
63
- event,
64
- elCtx,
65
- html,
66
- get,
67
- post,
68
- put,
69
- patch,
70
- del,
71
- ...Object.values(data),
72
- ...[...refs.values()],
73
- );
74
- // guard to reduce observer recursion (optional but helpful)
75
- if (prop === "innerHTML") {
57
+ const result = fn($, state, event, elCtx, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
58
+ if (prop == "innerHTML") {
76
59
  isUpdatingDOM = true;
77
60
  el.innerHTML = result;
78
61
  isUpdatingDOM = false;
79
- } else if (prop in el) {
62
+ } else if(prop == "class" && typeof(result) == "object"){
63
+ const add = [];
64
+ const remove = [];
65
+
66
+ for (const [key, value] of Object.entries(result)) {
67
+ (value ? add : remove).push(key);
68
+ }
69
+
70
+ el.classList.add(...add);
71
+ 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) {
80
75
  el[prop] = result;
81
76
  } else {
82
77
  el.setAttribute(prop, result);
@@ -85,286 +80,132 @@ export default function helium(data = {}) {
85
80
 
86
81
  function compileExpression(expr, withReturn = false) {
87
82
  try {
88
- return new Function(
89
- "$",
90
- "$data",
91
- "$event",
92
- "$el",
93
- "$html",
94
- "$get",
95
- "$post",
96
- "$put",
97
- "$patch",
98
- "$delete",
99
- ...Object.keys(data),
100
- ...[...refs.keys()],
101
- `with($data) { ${withReturn ? "return" : ""} (${expr.trim()}) }`,
102
- );
83
+ return new Function("$", "$data", "$event", "$el", "$html", "$get", "$post", "$put", "$patch", "$delete",
84
+ ...Object.keys(data), ...[...refs.keys()],
85
+ `with($data) { ${withReturn ? "return" : ""} (${expr.trim()}) }`);
103
86
  } catch (err) {
104
87
  return () => expr;
105
88
  }
106
89
  }
107
90
 
108
- // processElements now returns an array of newly-created binding objects
109
- function processElements(element, addEvents = ue) {
110
- const newlyAddedBindings = [];
91
+ function processElements(element) {
92
+ 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", "");
111
95
 
112
- // quick guard: avoid re-processing same node
113
- if (
114
- element.nodeType === 1 &&
115
- element.hasAttribute &&
116
- element.hasAttribute("data-helium-processed")
117
- )
118
- return newlyAddedBindings;
119
- if (element.nodeType === 1 && element.setAttribute)
120
- element.setAttribute("data-helium-processed", "");
96
+ const heElements = [element, ...Array.from(element.querySelectorAll("*")).filter((el) =>
97
+ Array.from(el.attributes).some((attr) => attr.name.startsWith("data-he-")))];
121
98
 
122
- const heliumElements = [
123
- element,
124
- ...Array.from(element.querySelectorAll("*")).filter((el) =>
125
- Array.from(el.attributes).some((attr) =>
126
- attr.name.startsWith("data-he-"),
127
- ),
128
- ),
129
- ];
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));
130
102
 
131
- const xpath = document.evaluate(
132
- ".//*[@*[starts-with(name(), '@') or starts-with(name(), ':')]]",
133
- element,
134
- null,
135
- XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
136
- null,
137
- );
138
- for (let i = 0; i < xpath.snapshotLength; i++)
139
- heliumElements.push(xpath.snapshotItem(i));
140
-
141
- // seed default state values (no binding creation here)
142
- heliumElements.forEach((el) => {
103
+ // Seed default state values
104
+ heElements.forEach((el) => {
143
105
  for (const { name, value } of el.attributes || []) {
144
- if (
145
- name == "@html" ||
146
- name == "data-he-html" ||
147
- name == "@text" ||
148
- name == "data-he-text" ||
149
- name == "@bind" ||
150
- name == "data-he-bind"
151
- ) {
106
+ if (["@html","data-he-html","@text","data-he-text","@bind","data-he-bind"].includes(name)) {
152
107
  try {
153
108
  new Function(`let ${value} = 1`);
154
- state[value] ||=
155
- name == "@bind" || name == "data-he-bind"
156
- ? el.value
157
- : el.textContent;
109
+ state[value] ||= ["@bind","data-he-bind"].includes(name) ? el.value : el.textContent;
158
110
  } catch (e) {}
159
111
  }
160
112
  }
161
113
  });
162
114
 
163
- // Register bindings for these elements and collect the newly added binding objects
164
- heliumElements.forEach((el) => {
165
- for (const { name, value } of el.attributes || []) {
166
- if (name == "@data" || name == "data-he") {
167
- Object.assign(
168
- state,
169
- compileExpression(value, true)(
170
- $,
171
- state,
172
- {},
173
- el,
174
- html,
175
- get,
176
- post,
177
- put,
178
- patch,
179
- del,
180
- ...Object.values(data),
181
- ...[...refs.values()],
182
- ),
183
- );
184
- }
185
- if (name == "@ref" || name == "data-he-ref") refs.set("$" + value, el);
186
115
 
187
- if (
188
- name == "@text" ||
189
- name == "data-he-text" ||
190
- name == "@html" ||
191
- name == "data-he-html"
192
- ) {
193
- Object.keys(state)
194
- .filter((key) => value.includes(key))
195
- .forEach((val) => {
196
- const b = {
197
- el,
198
- prop:
199
- name == "@text" || name == "data-he-text"
200
- ? "textContent"
201
- : "innerHTML",
202
- expr: value,
203
- fn: compileExpression(value, true),
204
- };
205
- bindings.set(val, [...(bindings.get(val) || []), b]);
206
- newlyAddedBindings.push(b);
207
- });
116
+ // 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()]);
119
+ for (const { name, value } of el.attributes || []) {
120
+ if (["@data","data-he"].includes(name)) Object.assign(state, execFn(value));
121
+ if (["@ref","data-he-ref"].includes(name)) refs.set("$" + value, el);
122
+
123
+ 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
+ });
208
129
  }
209
130
 
210
- if (name == "@bind" || name == "data-he-bind") {
131
+ if (["@bind","data-he-bind"].includes(name)) {
211
132
  el.addEventListener("input", (e) => (state[value] = e.target.value));
212
- const b = {
213
- el,
214
- prop: "value",
215
- expr: value,
216
- fn: compileExpression(value, true),
217
- };
133
+ const b = { el, prop: "value", expr: value, fn: compileExpression(value, true) };
218
134
  bindings.set(value, [...(bindings.get(value) || []), b]);
219
- newlyAddedBindings.push(b);
135
+ newBindings.push(b);
220
136
  el.value = state[value];
221
137
  }
222
138
 
223
- if (
224
- name == "@hidden" ||
225
- name == "@visible" ||
226
- name == "data-he-hidden" ||
227
- name == "data-he-visible"
228
- ) {
229
- Object.keys(state)
230
- .filter((key) => value.includes(key))
231
- .forEach((val) => {
232
- const b = {
233
- el,
234
- prop: "hidden",
235
- expr: value,
236
- fn: compileExpression(
237
- `${name == "@hidden" || name == "data-he-hidden" ? "!" : ""}!(${value})`,
238
- true,
239
- ),
240
- };
241
- bindings.set(val, [...(bindings.get(val) || []), b]);
242
- newlyAddedBindings.push(b);
243
- });
139
+ 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
+ });
244
145
  }
245
146
 
246
147
  if (name.startsWith(":")) {
247
- Object.keys(state)
248
- .filter((key) => value.includes(key))
249
- .forEach((val) => {
250
- const b = {
251
- el,
252
- prop: name.split(":")[1],
253
- expr: value,
254
- fn: compileExpression(value, true),
255
- };
256
- bindings.set(val, [...(bindings.get(val) || []), b]);
257
- newlyAddedBindings.push(b);
258
- });
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
+ });
259
153
  }
260
154
 
261
- if (name == "@init" || name == "data-he-init") {
262
- compileExpression(value, false)(
263
- $,
264
- state,
265
- undefined,
266
- el,
267
- html,
268
- get,
269
- post,
270
- put,
271
- patch,
272
- del,
273
- ...Object.values(data),
274
- ...[...refs.values()],
275
- );
276
- } else if (
277
- (name.startsWith("@") || name.startsWith("data-he-on")) &&
278
- addEvents
279
- ) {
280
- // event handling — keep this as before (register listeners)
281
- const [eventName, ...modifiers] = name
282
- .slice(name.startsWith("@") ? 1 : 10)
283
- .split(".");
284
- const receiver =
285
- modifiers.includes("outside") || modifiers.includes("document")
286
- ? document
287
- : el;
155
+ 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()]);
157
+ } else if (name.startsWith("@") || name.startsWith("data-he-on")) {
158
+ const [eventName, ...mods] = name.slice(name.startsWith("@") ? 1 : 10).split(".");
159
+ const receiver = mods.includes("outside") || mods.includes("document") ? document : el;
288
160
  const debounce = (fn, delay) => {
289
161
  let timeout;
290
- return function (...args) {
162
+ return (...args) => {
291
163
  clearTimeout(timeout);
292
- timeout = setTimeout(() => fn.apply(this, args), delay);
164
+ timeout = setTimeout(() => fn(...args), delay);
293
165
  };
294
166
  };
295
167
 
296
168
  let debounceDelay = 0;
297
- const debounceModifier = modifiers.find((m) =>
298
- m.startsWith("debounce"),
299
- );
300
- if (debounceModifier) {
301
- const time = debounceModifier.split(":")[1];
302
- if (time && !isNaN(time)) debounceDelay = Number(time);
303
- else debounceDelay = 300;
169
+ const debounceMod = mods.find((m) => m.startsWith("debounce"));
170
+ if (debounceMod) {
171
+ const time = debounceMod.split(":")[1];
172
+ debounceDelay = time && !isNaN(time) ? Number(time) : 300;
304
173
  }
305
174
 
306
175
  function _handler(e) {
307
- if (modifiers.includes("prevent")) e.preventDefault();
308
- const keyModifiers = {
309
- shift: "shiftKey",
310
- ctrl: "ctrlKey",
311
- alt: "altKey",
312
- meta: "metaKey",
313
- };
314
- for (const [mod, prop] of Object.entries(keyModifiers)) {
315
- if (modifiers.includes(mod) && !e[prop]) return;
176
+ if (mods.includes("prevent")) e.preventDefault();
177
+ const keyMods = { shift: "shiftKey", ctrl: "ctrlKey", alt: "altKey", meta: "metaKey" };
178
+ for (const [mod, prop] of Object.entries(keyMods)) {
179
+ if (mods.includes(mod) && !e[prop]) return;
316
180
  }
317
- if (["keydown", "keyup", "keypress"].includes(eventName)) {
318
- const last = modifiers[modifiers.length - 1];
181
+ if (["keydown","keyup","keypress"].includes(eventName)) {
182
+ const last = mods[mods.length - 1];
319
183
  if (last) {
320
- let keyName = e.key;
321
- if (keyName === " ") keyName = "Space";
322
- else if (keyName === "Escape") keyName = "Esc";
184
+ let keyName = e.key === " " ? "Space" : e.key === "Escape" ? "Esc" : e.key;
323
185
  if (keyName.toLowerCase() !== last.toLowerCase()) return;
324
186
  }
325
187
  }
326
-
327
- if (!modifiers.includes("outside") || !el.contains(e.target)) {
328
- compileExpression(value, false)(
329
- $,
330
- state,
331
- e,
332
- el,
333
- html,
334
- get,
335
- post,
336
- put,
337
- patch,
338
- del,
339
- ...Object.values(data),
340
- ...[...refs.values()],
341
- );
188
+ 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()]);
342
190
  }
343
- if (modifiers.includes("once"))
344
- el.removeEventListener(eventName, debouncedHandler);
191
+ if (mods.includes("once")) el.removeEventListener(eventName, debouncedHandler);
345
192
  }
346
193
 
347
- const debouncedHandler =
348
- debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
194
+ const debouncedHandler = debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
349
195
  receiver.addEventListener(eventName, debouncedHandler);
350
196
  }
351
- } // end attributes loop
352
- }); // end heliumElements.forEach
197
+ }
198
+ });
353
199
 
354
- return newlyAddedBindings;
355
- } // end processElements
200
+ return newBindings;
201
+ }
356
202
 
357
- // set up mutation observer that only processes and applies bindings for newly added nodes
358
203
  const observer = new MutationObserver((mutations) => {
359
- if (isUpdatingDOM) return; // ignore mutations we caused directly
204
+ if (isUpdatingDOM) return;
360
205
  for (const m of mutations) {
361
206
  for (const node of m.addedNodes) {
362
- if (
363
- node.nodeType === 1 &&
364
- !node.hasAttribute("data-helium-processed")
365
- ) {
366
- const newly = processElements(node, false);
367
- // apply only the bindings we just created for this node
207
+ if (node.nodeType === 1 && !node.hasAttribute("data-helium-processed")) {
208
+ const newly = processElements(node);
368
209
  newly.forEach(applyBinding);
369
210
  }
370
211
  }
@@ -372,9 +213,6 @@ export default function helium(data = {}) {
372
213
  });
373
214
  observer.observe(root, { childList: true, subtree: true });
374
215
 
375
- const initialBindings = processElements(root);
216
+ processElements(root);
376
217
  for (const [key, items] of bindings.entries()) items.forEach(applyBinding);
377
- ue = false;
378
218
  }
379
-
380
- 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.2",
3
+ "version": "0.8.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
+ });