@daz4126/helium 0.30.0 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/helium-lite.js ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Helium Lite - Lightweight reactive library
3
+ * Core features only: @data, @text, @html, @bind, :attr, @ref, @calculate, @effect, @init, events
4
+ * No AJAX, no @import, no SSE, no Turbo
5
+ */
6
+
7
+ const parseEx = v => { try { return Function(`return(${v})`)() } catch { return v } };
8
+ const tryNum = v => { const t = String(v).trim(), n = +t; return t && !isNaN(n) ? n : v };
9
+ const isValidIdentifier = v => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v);
10
+ const RESERVED = new Set(['undefined', 'null', 'true', 'false', 'NaN', 'Infinity', 'this', 'arguments']);
11
+ const KEY_MODS = { shift: "shiftKey", ctrl: "ctrlKey", alt: "altKey", meta: "metaKey" };
12
+ const debounce = (f, d) => { let t; return (...a) => (clearTimeout(t), t = setTimeout(f, d, ...a)) };
13
+
14
+ const ATTR_REGEX = /^(@|:|data-he)/;
15
+
16
+ const match = (name, ...attrs) => {
17
+ const p = name.split(/[.:]/)[0];
18
+ if (p === ":" || p === "") return false;
19
+ return attrs.some(a => p === `@${a}` || p === `data-he-${a}`);
20
+ };
21
+
22
+ let HELIUM = null;
23
+
24
+ async function helium(initialState = {}) {
25
+ const inits = [];
26
+ const ALL = Symbol("all");
27
+ const root = document.querySelector('[\\@helium]') || document.querySelector('[data-helium]') || document.body;
28
+
29
+ if (!HELIUM) {
30
+ HELIUM = {
31
+ observer: null,
32
+ bindings: new Map(),
33
+ refs: new Map(),
34
+ listeners: new WeakMap(),
35
+ processed: new WeakSet(),
36
+ parentKeys: new WeakMap(),
37
+ fnCache: new Map(),
38
+ proxyCache: new WeakMap()
39
+ };
40
+ }
41
+
42
+ const $ = s => document.querySelector(s);
43
+ const html = s => Object.assign(document.createElement("template"), { innerHTML: s.trim() }).content.firstChild;
44
+
45
+ const applyingBindings = new Set();
46
+
47
+ const safeApplyBinding = (b) => {
48
+ if (applyingBindings.has(b)) return;
49
+ applyingBindings.add(b);
50
+ try {
51
+ applyBinding(b);
52
+ } finally {
53
+ applyingBindings.delete(b);
54
+ }
55
+ };
56
+
57
+ const handler = {
58
+ has(t, p) {
59
+ if (typeof p === 'string' && p.startsWith('$')) return false;
60
+ if (Reflect.has(t, p)) return true;
61
+ if (typeof globalThis[p] !== 'undefined') return false;
62
+ return true;
63
+ },
64
+ get(t, p, r) {
65
+ const v = Reflect.get(t, p, r);
66
+ if (v && typeof v === "object") {
67
+ if (HELIUM.proxyCache.has(v)) return HELIUM.proxyCache.get(v);
68
+ const proxy = new Proxy(v, handler);
69
+ HELIUM.proxyCache.set(v, proxy);
70
+ HELIUM.parentKeys.set(v, p);
71
+ return proxy;
72
+ }
73
+ return v;
74
+ },
75
+ set: (t, p, v) => {
76
+ const res = Reflect.set(t, p, v);
77
+ HELIUM.bindings.get(p)?.forEach(safeApplyBinding);
78
+ const parentKey = HELIUM.parentKeys.get(t);
79
+ if (parentKey) HELIUM.bindings.get(parentKey)?.forEach(safeApplyBinding);
80
+ HELIUM.bindings.get(ALL)?.forEach(safeApplyBinding);
81
+ return res;
82
+ }
83
+ };
84
+
85
+ const state = new Proxy({}, handler);
86
+ Object.assign(state, initialState);
87
+
88
+ const getRefsObject = () => {
89
+ const refs = {};
90
+ for (const [name, element] of HELIUM.refs) {
91
+ refs[name] = element;
92
+ }
93
+ return refs;
94
+ };
95
+
96
+ const createScope = (el, event = {}) => {
97
+ return {
98
+ $,
99
+ $data: state,
100
+ $event: event,
101
+ $el: el,
102
+ $html: html,
103
+ ...getRefsObject()
104
+ };
105
+ };
106
+
107
+ const compile = (expr, withReturn = true) => {
108
+ const key = `${withReturn}:${expr}`;
109
+ if (HELIUM.fnCache.has(key)) return HELIUM.fnCache.get(key);
110
+ try {
111
+ const fn = new Function(
112
+ "$scope",
113
+ withReturn
114
+ ? `with($scope){with($scope.$data){return(${expr.trim()})}}`
115
+ : `with($scope){with($scope.$data){${expr.trim()}}}`
116
+ );
117
+ const compiled = { execute: (scope) => fn(scope), getIds: null };
118
+ HELIUM.fnCache.set(key, compiled);
119
+ return compiled;
120
+ } catch {
121
+ const compiled = { execute: () => expr, getIds: null };
122
+ HELIUM.fnCache.set(key, compiled);
123
+ return compiled;
124
+ }
125
+ };
126
+
127
+ function applyBinding(b, e = {}, elCtx = b.el) {
128
+ const { el, prop, compiled, calc, isHiddenAttr, isRadio } = b;
129
+ const scope = createScope(elCtx, e);
130
+ const r = compiled.execute(scope);
131
+ if (calc) state[calc] = r;
132
+
133
+ if (prop === "innerHTML") {
134
+ const content = Array.isArray(r) ? r.join`` : r;
135
+ return typeof Idiomorph === "object"
136
+ ? Idiomorph.morph(el, content, { morphStyle: 'innerHTML' })
137
+ : el.innerHTML = content;
138
+ }
139
+ if (prop === "class" && r && typeof r === "object")
140
+ return Object.entries(r).forEach(([k, v]) =>
141
+ k.split(/\s+/).forEach(c => el.classList.toggle(c, v)));
142
+
143
+ if (prop === "style" && r && typeof r === "object")
144
+ return el.style.cssText = Object.entries(r)
145
+ .filter(([_, v]) => v != null && v !== false)
146
+ .map(([k, v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${v}`)
147
+ .join("; ");
148
+
149
+ if (prop === "hidden") {
150
+ el.hidden = isHiddenAttr ? !!r : !r;
151
+ return;
152
+ }
153
+
154
+ if (prop in el) {
155
+ if (isRadio && prop !== "checked") el.checked = el.value === r;
156
+ else if (el.type === "radio" && prop !== "checked") el.checked = el.value === r;
157
+ else el[prop] = prop === "textContent" ? (r ?? '') : parseEx(r);
158
+ return;
159
+ }
160
+
161
+ el.setAttribute(prop, parseEx(r));
162
+ }
163
+
164
+ const trackDependenciesProxy = (compiled, el, excludeChanged = false) => {
165
+ const accessed = excludeChanged ? new Map() : new Set();
166
+ const trackProxy = new Proxy(state, {
167
+ has(target, prop) {
168
+ return true;
169
+ },
170
+ get(target, prop) {
171
+ if (typeof prop == 'string') {
172
+ excludeChanged ? !accessed.has(prop) && accessed.set(prop, target[prop]) : accessed.add(prop);
173
+ }
174
+ const val = target[prop];
175
+ return typeof val == "object" && val != null ? new Proxy(val, this) : val;
176
+ }
177
+ });
178
+
179
+ try {
180
+ const scope = {
181
+ $,
182
+ $data: trackProxy,
183
+ $event: {},
184
+ $el: el,
185
+ $html: html,
186
+ ...getRefsObject()
187
+ };
188
+ compiled.execute(scope);
189
+ } catch {}
190
+
191
+ return excludeChanged ? [...accessed.keys()].filter(prop => state[prop] === accessed.get(prop)) : [...accessed];
192
+ };
193
+
194
+ const getDependencies = (compiled, el, excludeChanged = false) => {
195
+ return trackDependenciesProxy(compiled, el, excludeChanged);
196
+ };
197
+
198
+ const cleanup = el => {
199
+ [el, ...el.querySelectorAll('*')].forEach(e => {
200
+ HELIUM.listeners.get(e)?.forEach(({ receiver, event, handler }) => receiver.removeEventListener(event, handler));
201
+ HELIUM.listeners.delete(e);
202
+ });
203
+ };
204
+
205
+ async function processElements(element) {
206
+ const newBindings = [];
207
+ const deferredBindings = [];
208
+
209
+ const heElements = [element, ...element.querySelectorAll("*")].filter(e => {
210
+ if (HELIUM.processed.has(e)) return false;
211
+ for (let i = 0; i < e.attributes.length; i++) {
212
+ if (ATTR_REGEX.test(e.attributes[i].name)) return true;
213
+ }
214
+ return false;
215
+ });
216
+
217
+ const addBinding = (val, b) => {
218
+ HELIUM.bindings.set(val, b.calc ? [b, ...(HELIUM.bindings.get(val) || [])] : [...(HELIUM.bindings.get(val) || []), b]);
219
+ b.calc ? newBindings.unshift(b) : newBindings.push(b);
220
+ };
221
+
222
+ heElements.forEach(el => {
223
+ HELIUM.processed.add(el);
224
+
225
+ const attrs = el.attributes;
226
+ const inputType = el.type?.toLowerCase();
227
+ const isCheckbox = inputType == "checkbox", isRadio = inputType == "radio", isSelect = el.tagName == "SELECT";
228
+
229
+ for (let i = 0; i < attrs.length; i++) {
230
+ const { name, value } = attrs[i];
231
+
232
+ if (!name.startsWith('@') && !name.startsWith(':') && !name.startsWith('data-he')) continue;
233
+
234
+ // Initialize state first if needed (skip reserved words, only if not already set)
235
+ if (match(name, "text", "html", "bind") && isValidIdentifier(value) && !RESERVED.has(value) && state[value] == null) {
236
+ state[value] = match(name, "bind") ? (el.type == "checkbox" ? el.checked : tryNum(el.value)) : tryNum(el.textContent);
237
+ }
238
+
239
+ // Process the attribute
240
+ if (name === "@data" || name === "data-he") {
241
+ Object.assign(state, parseEx(value));
242
+ }
243
+ else if (name.startsWith(":") || name.startsWith("data-he-attr:")) {
244
+ const propName = name.startsWith(":") ? name.slice(1) : name.slice(13);
245
+ deferredBindings.push({ el, prop: propName, compiled: compile(value, true) });
246
+ }
247
+ else if (match(name, "ref")) {
248
+ HELIUM.refs.set("$" + value, el);
249
+ }
250
+ else if (match(name, "text", "html")) {
251
+ deferredBindings.push({ el, prop: match(name, "text") ? "textContent" : "innerHTML", compiled: compile(value, true) });
252
+ }
253
+ else if (match(name, "bind")) {
254
+ const event = (isCheckbox || isRadio || isSelect) ? "change" : "input";
255
+ const prop = isCheckbox ? "checked" : "value";
256
+ const inputHandler = e => state[value] = isCheckbox ? e.target.checked : e.target.value;
257
+ el.addEventListener(event, inputHandler);
258
+ if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
259
+ HELIUM.listeners.get(el).push({ receiver: el, event, handler: inputHandler });
260
+ deferredBindings.push({ el, prop, compiled: compile(value, true), isRadio });
261
+ if (isCheckbox) el.checked = !!state[value];
262
+ else if (isRadio) el.checked = el.value == state[value];
263
+ else el.value = state[value] ?? "";
264
+ }
265
+ else if (match(name, "hidden", "visible")) {
266
+ const isHidden = match(name, "hidden");
267
+ deferredBindings.push({ el, prop: "hidden", compiled: compile(value, true), isHiddenAttr: isHidden });
268
+ }
269
+ else if (match(name, "calculate")) {
270
+ const calcName = name.split(":")[1];
271
+ if (!(calcName in state)) state[calcName] = undefined;
272
+ deferredBindings.push({ el, calc: calcName, prop: null, compiled: compile(value, true) });
273
+ }
274
+ else if (match(name, "effect")) {
275
+ deferredBindings.push({ el, prop: null, compiled: compile(value, true), keys: name.split(":").slice(1) });
276
+ }
277
+ else if (match(name, "init")) {
278
+ inits.push({ compiled: compile(value, true), el });
279
+ }
280
+ else if (name.startsWith("@") || name.startsWith("data-he")) {
281
+ const fullName = name.startsWith("@") ? name.slice(1) : name.slice(8);
282
+ const [event, ...mods] = fullName.split(".");
283
+ const receiver = mods.includes("outside") || mods.includes("document") ? document : el;
284
+ const debounceMod = mods.find(m => m.startsWith("debounce"));
285
+ const debounceDelay = debounceMod ? (t => t && !isNaN(t) ? Number(t) : 300)(debounceMod.split(":")[1]) : 0;
286
+ const _handler = e => {
287
+ if (mods.includes("prevent")) e.preventDefault();
288
+ if (mods.includes("stop")) e.stopPropagation();
289
+ for (const [mod, prop] of Object.entries(KEY_MODS)) if (mods.includes(mod) && !e[prop]) return;
290
+ if (["keydown", "keyup", "keypress"].includes(event)) {
291
+ const last = mods[mods.length - 1];
292
+ if (last && !["prevent", "once", "outside", "document", "stop"].includes(last) && !last.startsWith("debounce") && !Object.keys(KEY_MODS).includes(last)) {
293
+ const keyName = e.key == " " ? "Space" : e.key == "Escape" ? "Esc" : e.key;
294
+ if (keyName.toLowerCase() !== last.toLowerCase()) return;
295
+ }
296
+ }
297
+ if (!mods.includes("outside") || !el.contains(e.target)) {
298
+ const scope = createScope(el, e);
299
+ const compiled = compile(value, true);
300
+ compiled.execute(scope);
301
+ }
302
+ if (mods.includes("once")) receiver.removeEventListener(event, handler);
303
+ };
304
+ const handler = debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
305
+ const eventOptions = mods.includes("once") ? { once: true } : undefined;
306
+ receiver.addEventListener(event, handler, eventOptions);
307
+ if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
308
+ HELIUM.listeners.get(el).push({ receiver, event, handler });
309
+ }
310
+ }
311
+ });
312
+
313
+ deferredBindings.forEach(b => {
314
+ const tracked = b.keys?.includes("*") ? [ALL] : getDependencies(b.compiled, b.el, true).concat(b.keys || []);
315
+ tracked.forEach(key => addBinding(key, b));
316
+ if (tracked.length === 0) {
317
+ newBindings.push(b);
318
+ }
319
+ });
320
+
321
+ return newBindings;
322
+ }
323
+
324
+ HELIUM.observer?.disconnect();
325
+ HELIUM.observer = new MutationObserver(async (ms) => {
326
+ for (const m of ms) {
327
+ m.removedNodes.forEach((n) => n.nodeType === 1 && cleanup(n));
328
+
329
+ for (const n of m.addedNodes) {
330
+ if (n.nodeType === 1 && !HELIUM.processed.has(n)) {
331
+ const bindings = await processElements(n);
332
+ bindings?.forEach(applyBinding);
333
+ }
334
+ }
335
+ }
336
+ });
337
+ HELIUM.observer.observe(root, { childList: true, subtree: true });
338
+
339
+ const initialBindings = await processElements(root);
340
+ initialBindings.forEach(applyBinding);
341
+ inits.forEach(({ compiled, el }) => {
342
+ const scope = createScope(el);
343
+ compiled.execute(scope);
344
+ });
345
+
346
+ return state;
347
+ }
348
+
349
+ function heliumTeardown() {
350
+ if (HELIUM?.observer) HELIUM.observer.disconnect();
351
+ HELIUM = null;
352
+ }
353
+
354
+ // Browser globals
355
+ if (typeof window !== 'undefined') {
356
+ window.helium = helium;
357
+ window.heliumTeardown = heliumTeardown;
358
+ }
359
+
360
+ // Auto-initialize
361
+ if (typeof document !== 'undefined') {
362
+ document.addEventListener("DOMContentLoaded", () => helium());
363
+ }
364
+
365
+ export { helium, heliumTeardown };
366
+ export default helium;
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { helium, heliumTeardown } from './helium-lite.js';
3
+
4
+ describe('Helium Lite', () => {
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
+ heliumTeardown();
15
+ document.body.innerHTML = '';
16
+ });
17
+
18
+ describe('Default State Values', () => {
19
+ it('should initialize state from @text element content', async () => {
20
+ container.innerHTML = '<span @text="name">John</span>';
21
+ const state = await helium();
22
+ expect(state.name).toBe('John');
23
+ });
24
+
25
+ it('should initialize state from @bind input value', async () => {
26
+ container.innerHTML = '<input @bind="email" value="test@example.com" />';
27
+ const state = await helium();
28
+ expect(state.email).toBe('test@example.com');
29
+ });
30
+
31
+ it('should initialize numeric state from @text', async () => {
32
+ container.innerHTML = '<span @text="count">42</span>';
33
+ const state = await helium();
34
+ expect(state.count).toBe(42);
35
+ });
36
+
37
+ it('should not override initial state with defaults', async () => {
38
+ container.innerHTML = '<span @text="name">Default</span>';
39
+ const state = await helium({ name: 'Custom' });
40
+ expect(state.name).toBe('Custom');
41
+ });
42
+
43
+ it('should initialize checkbox state from checked attribute', async () => {
44
+ container.innerHTML = '<input type="checkbox" @bind="agreed" checked />';
45
+ const state = await helium();
46
+ expect(state.agreed).toBe(true);
47
+ });
48
+ });
49
+
50
+ describe('Basic Reactivity', () => {
51
+ it('should update text on state change', async () => {
52
+ container.innerHTML = '<div @text="message"></div>';
53
+ const state = await helium({ message: 'Hello' });
54
+ expect(container.querySelector('div').textContent).toBe('Hello');
55
+
56
+ state.message = 'World';
57
+ await new Promise(r => setTimeout(r, 0));
58
+ expect(container.querySelector('div').textContent).toBe('World');
59
+ });
60
+
61
+ it('should handle @bind two-way binding', async () => {
62
+ container.innerHTML = '<input @bind="name" /><span @text="name"></span>';
63
+ const state = await helium({ name: 'John' });
64
+
65
+ const input = container.querySelector('input');
66
+ expect(input.value).toBe('John');
67
+
68
+ input.value = 'Jane';
69
+ input.dispatchEvent(new Event('input'));
70
+ await new Promise(r => setTimeout(r, 0));
71
+
72
+ expect(state.name).toBe('Jane');
73
+ expect(container.querySelector('span').textContent).toBe('Jane');
74
+ });
75
+ });
76
+
77
+ describe('Events', () => {
78
+ it('should handle click events', async () => {
79
+ container.innerHTML = '<button @click="count++">Click</button><span @text="count"></span>';
80
+ const state = await helium({ count: 0 });
81
+
82
+ container.querySelector('button').click();
83
+ await new Promise(r => setTimeout(r, 0));
84
+ expect(state.count).toBe(1);
85
+ });
86
+ });
87
+
88
+ describe('@calculate', () => {
89
+ it('should create computed properties', async () => {
90
+ container.innerHTML = '<div @calculate:double="count * 2"></div><span @text="double"></span>';
91
+ const state = await helium({ count: 5 });
92
+ expect(container.querySelector('span').textContent).toBe('10');
93
+ });
94
+ });
95
+
96
+ describe('@ref', () => {
97
+ it('should create element references', async () => {
98
+ container.innerHTML = '<div @ref="myDiv"></div><button @click="$myDiv.textContent = \'Changed\'">Change</button>';
99
+ await helium({});
100
+
101
+ container.querySelector('button').click();
102
+ await new Promise(r => setTimeout(r, 0));
103
+ expect(container.querySelector('div').textContent).toBe('Changed');
104
+ });
105
+ });
106
+
107
+ describe('Dynamic Attributes', () => {
108
+ it('should bind :class', async () => {
109
+ container.innerHTML = '<div :class="className"></div>';
110
+ await helium({ className: 'active' });
111
+ expect(container.querySelector('div').getAttribute('class')).toBe('active');
112
+ });
113
+
114
+ it('should handle :class with object syntax', async () => {
115
+ container.innerHTML = '<div :class="{ active: isActive }"></div>';
116
+ await helium({ isActive: true });
117
+ expect(container.querySelector('div').classList.contains('active')).toBe(true);
118
+ });
119
+ });
120
+ });