@daz4126/helium 0.7.3 → 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,80 +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 = (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
+ fetch(url, {
13
+ method,
14
+ headers: {
15
+ "Content-Type": "application/json",
16
+ Accept: "text/vnd.turbo-stream.html, application/json, application/vnd.api+json, text/html, */*",
17
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content || ""
18
+ },
19
+ body: method === "GET" ? null : JSON.stringify(opts),
20
+ credentials: "same-origin"
24
21
  })
25
- .then((r) =>
26
- r[r.headers.get("content-type")?.includes("json") ? "json" : "text"](),
22
+ .then(r =>
23
+ r.headers.get("content-type")?.includes("application/json") ? r.json() : r.text()
27
24
  )
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));
25
+ .then(d => state[target] = d)
26
+ .catch(e => console.error("AJAX error:", e.message));
27
+ };
37
28
 
38
- const state = new Proxy(data, {
39
- get(target, prop) {
40
- 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;
41
36
  },
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));
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
+ }
49
47
  }
50
- return true;
51
- },
52
- });
48
+ return result;
49
+ }
50
+ };
53
51
 
52
+ const state = new Proxy(data, handler);
54
53
  let isUpdatingDOM = false;
55
54
 
56
55
  function applyBinding(binding, event = {}, elCtx = binding.el) {
57
56
  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") {
57
+ const result = fn($, state, event, elCtx, html, get, post, put, patch, del, ...Object.values(data), ...[...refs.values()]);
58
+ if (prop == "innerHTML") {
74
59
  isUpdatingDOM = true;
75
60
  el.innerHTML = result;
76
61
  isUpdatingDOM = false;
77
- } 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) {
78
75
  el[prop] = result;
79
76
  } else {
80
77
  el.setAttribute(prop, result);
@@ -83,286 +80,132 @@ export default function helium(data = {}) {
83
80
 
84
81
  function compileExpression(expr, withReturn = false) {
85
82
  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
- );
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()}) }`);
101
86
  } catch (err) {
102
87
  return () => expr;
103
88
  }
104
89
  }
105
90
 
106
- // processElements now returns an array of newly-created binding objects
107
- function processElements(element, addEvents = ue) {
108
- 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", "");
109
95
 
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", "");
96
+ const heElements = [element, ...Array.from(element.querySelectorAll("*")).filter((el) =>
97
+ Array.from(el.attributes).some((attr) => attr.name.startsWith("data-he-")))];
119
98
 
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
- ];
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));
128
102
 
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) => {
103
+ // Seed default state values
104
+ heElements.forEach((el) => {
141
105
  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
- ) {
106
+ if (["@html","data-he-html","@text","data-he-text","@bind","data-he-bind"].includes(name)) {
150
107
  try {
151
108
  new Function(`let ${value} = 1`);
152
- state[value] ||=
153
- name == "@bind" || name == "data-he-bind"
154
- ? el.value
155
- : el.textContent;
109
+ state[value] ||= ["@bind","data-he-bind"].includes(name) ? el.value : el.textContent;
156
110
  } catch (e) {}
157
111
  }
158
112
  }
159
113
  });
160
114
 
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
115
 
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
- });
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
+ });
206
129
  }
207
130
 
208
- if (name == "@bind" || name == "data-he-bind") {
131
+ if (["@bind","data-he-bind"].includes(name)) {
209
132
  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
- };
133
+ const b = { el, prop: "value", expr: value, fn: compileExpression(value, true) };
216
134
  bindings.set(value, [...(bindings.get(value) || []), b]);
217
- newlyAddedBindings.push(b);
135
+ newBindings.push(b);
218
136
  el.value = state[value];
219
137
  }
220
138
 
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
- });
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
+ });
242
145
  }
243
146
 
244
147
  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
- });
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
+ });
257
153
  }
258
154
 
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;
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;
286
160
  const debounce = (fn, delay) => {
287
161
  let timeout;
288
- return function (...args) {
162
+ return (...args) => {
289
163
  clearTimeout(timeout);
290
- timeout = setTimeout(() => fn.apply(this, args), delay);
164
+ timeout = setTimeout(() => fn(...args), delay);
291
165
  };
292
166
  };
293
167
 
294
168
  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;
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;
302
173
  }
303
174
 
304
175
  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;
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;
314
180
  }
315
- if (["keydown", "keyup", "keypress"].includes(eventName)) {
316
- const last = modifiers[modifiers.length - 1];
181
+ if (["keydown","keyup","keypress"].includes(eventName)) {
182
+ const last = mods[mods.length - 1];
317
183
  if (last) {
318
- let keyName = e.key;
319
- if (keyName === " ") keyName = "Space";
320
- else if (keyName === "Escape") keyName = "Esc";
184
+ let keyName = e.key === " " ? "Space" : e.key === "Escape" ? "Esc" : e.key;
321
185
  if (keyName.toLowerCase() !== last.toLowerCase()) return;
322
186
  }
323
187
  }
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
- );
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()]);
340
190
  }
341
- if (modifiers.includes("once"))
342
- el.removeEventListener(eventName, debouncedHandler);
191
+ if (mods.includes("once")) el.removeEventListener(eventName, debouncedHandler);
343
192
  }
344
193
 
345
- const debouncedHandler =
346
- debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
194
+ const debouncedHandler = debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
347
195
  receiver.addEventListener(eventName, debouncedHandler);
348
196
  }
349
- } // end attributes loop
350
- }); // end heliumElements.forEach
197
+ }
198
+ });
351
199
 
352
- return newlyAddedBindings;
353
- } // end processElements
200
+ return newBindings;
201
+ }
354
202
 
355
- // set up mutation observer that only processes and applies bindings for newly added nodes
356
203
  const observer = new MutationObserver((mutations) => {
357
- if (isUpdatingDOM) return; // ignore mutations we caused directly
204
+ if (isUpdatingDOM) return;
358
205
  for (const m of mutations) {
359
206
  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
207
+ if (node.nodeType === 1 && !node.hasAttribute("data-helium-processed")) {
208
+ const newly = processElements(node);
366
209
  newly.forEach(applyBinding);
367
210
  }
368
211
  }
@@ -370,9 +213,6 @@ export default function helium(data = {}) {
370
213
  });
371
214
  observer.observe(root, { childList: true, subtree: true });
372
215
 
373
- const initialBindings = processElements(root);
216
+ processElements(root);
374
217
  for (const [key, items] of bindings.entries()) items.forEach(applyBinding);
375
- ue = false;
376
218
  }
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.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
+ });