@daz4126/helium 1.0.0-beta.3 → 1.0.0-beta.5
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/.claude/settings.local.json +2 -1
- package/README.md +78 -5
- package/examples/index.html +13 -28
- package/examples/todo.css +109 -0
- package/examples/todo.html +46 -0
- package/examples/todo.js +36 -0
- package/helium-lite.js +216 -100
- package/helium.js +7 -17
- package/helium.test.js +79 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Here's a simple example of a button that counts the number of times it has been
|
|
|
12
12
|
|
|
13
13
|
It's really simple to use - just sprinkle the magic @attributes into your HTML and watch it come alive!
|
|
14
14
|
|
|
15
|
-
[See more examples here](https://codepen.io/daz4126/pen/ogxGMX)
|
|
15
|
+
[See more examples here](https://codepen.io/daz4126/pen/ogxGMX) | [TodoMVC Example](https://codepen.io/daz4126/pen/gbMjdZO)
|
|
16
16
|
|
|
17
17
|
## Why Helium?
|
|
18
18
|
|
|
@@ -20,8 +20,68 @@ Helium is designed for developers who want:
|
|
|
20
20
|
|
|
21
21
|
- **Lightweight** - Just over 3KB minified and gzipped
|
|
22
22
|
- **Powerful** - Declarative JavaScript in your HTML
|
|
23
|
-
- **Zero build step** Works directly in the browser with no compiling
|
|
24
|
-
- **Easy to learn** If you know HTML and basic JavaScript, you're ready
|
|
23
|
+
- **Zero build step** - Works directly in the browser with no compiling
|
|
24
|
+
- **Easy to learn** - If you know HTML and basic JavaScript, you're ready
|
|
25
|
+
|
|
26
|
+
## Versions
|
|
27
|
+
|
|
28
|
+
Helium comes in three versions so you can pick the right balance of features and size for your project:
|
|
29
|
+
|
|
30
|
+
### Helium (Standard)
|
|
31
|
+
|
|
32
|
+
The full-featured version with everything included.
|
|
33
|
+
|
|
34
|
+
- All directives (`@text`, `@html`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`, etc.)
|
|
35
|
+
- Event handlers with modifiers
|
|
36
|
+
- HTTP requests (`@get`, `@post`, `@put`, `@patch`, `@delete`)
|
|
37
|
+
- Module imports (`@import`)
|
|
38
|
+
- Server-Sent Events (SSE)
|
|
39
|
+
- Turbo/Hotwire integration
|
|
40
|
+
|
|
41
|
+
**Best for:** Most projects where you want the full power of Helium without worrying about CSP restrictions.
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
import helium from "@daz4126/helium"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Helium Lite
|
|
48
|
+
|
|
49
|
+
A slimmed-down version with just the core reactivity features.
|
|
50
|
+
|
|
51
|
+
- Core directives (`@text`, `@html`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`)
|
|
52
|
+
- Event handlers with modifiers
|
|
53
|
+
- No HTTP requests, imports, SSE, or Turbo integration
|
|
54
|
+
|
|
55
|
+
**Best for:** Simple interactive pages where you only need reactivity and don't need server communication or module imports.
|
|
56
|
+
|
|
57
|
+
```javascript
|
|
58
|
+
import helium from "@daz4126/helium/lite"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Helium CSP
|
|
62
|
+
|
|
63
|
+
A Content Security Policy safe version that doesn't require `unsafe-eval`. It uses [jexpr](https://github.com/nicolo-ribaudo/jexpr) as its expression engine instead of `new Function()`, while keeping the full feature set.
|
|
64
|
+
|
|
65
|
+
- All the same features as the standard version
|
|
66
|
+
- CSP-compliant — no `unsafe-eval` needed
|
|
67
|
+
|
|
68
|
+
**Best for:** Projects with strict Content Security Policies that prohibit `unsafe-eval`.
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
import helium from "@daz4126/helium/csp"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Comparison
|
|
75
|
+
|
|
76
|
+
| Feature | Standard | Lite | CSP |
|
|
77
|
+
|---------|----------|------|-----|
|
|
78
|
+
| Core directives | ✅ | ✅ | ✅ |
|
|
79
|
+
| Event handlers | ✅ | ✅ | ✅ |
|
|
80
|
+
| HTTP requests | ✅ | ❌ | ✅ |
|
|
81
|
+
| `@import` | ✅ | ❌ | ✅ |
|
|
82
|
+
| SSE | ✅ | ❌ | ✅ |
|
|
83
|
+
| Turbo integration | ✅ | ❌ | ✅ |
|
|
84
|
+
| CSP safe | ❌ | ❌ | ✅ |
|
|
25
85
|
|
|
26
86
|
## Installation
|
|
27
87
|
|
|
@@ -30,9 +90,20 @@ Helium is designed for developers who want:
|
|
|
30
90
|
Just import from the CDN in a script tag directly in your HTML page:
|
|
31
91
|
|
|
32
92
|
```html
|
|
93
|
+
<!-- Standard -->
|
|
33
94
|
<script type="module">
|
|
34
95
|
import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium.js';
|
|
35
96
|
</script>
|
|
97
|
+
|
|
98
|
+
<!-- Lite -->
|
|
99
|
+
<script type="module">
|
|
100
|
+
import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium-lite.js';
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<!-- CSP -->
|
|
104
|
+
<script type="module">
|
|
105
|
+
import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium-csp.js';
|
|
106
|
+
</script>
|
|
36
107
|
```
|
|
37
108
|
|
|
38
109
|
### NPM
|
|
@@ -41,10 +112,12 @@ Just import from the CDN in a script tag directly in your HTML page:
|
|
|
41
112
|
npm install @daz4126/helium
|
|
42
113
|
```
|
|
43
114
|
|
|
44
|
-
Then
|
|
115
|
+
Then import the version you need:
|
|
45
116
|
|
|
46
117
|
```javascript
|
|
47
|
-
import helium from "@daz4126/helium"
|
|
118
|
+
import helium from "@daz4126/helium" // Standard
|
|
119
|
+
import helium from "@daz4126/helium/lite" // Lite
|
|
120
|
+
import helium from "@daz4126/helium/csp" // CSP
|
|
48
121
|
```
|
|
49
122
|
|
|
50
123
|
### Automatic Initialization
|
package/examples/index.html
CHANGED
|
@@ -220,17 +220,11 @@
|
|
|
220
220
|
</head>
|
|
221
221
|
<body>
|
|
222
222
|
|
|
223
|
-
<div class="page" data-helium
|
|
223
|
+
<div class="page" data-helium>
|
|
224
224
|
<h1>Helium Examples</h1>
|
|
225
225
|
|
|
226
226
|
<div class="examples">
|
|
227
227
|
|
|
228
|
-
<!-- @data Test -->
|
|
229
|
-
<div class="card" data-he="{ word: 'Hello',number: 42 }">
|
|
230
|
-
<h2 data-he-text="word"></h2>
|
|
231
|
-
<p>Number: <b @text="number"></b></p>
|
|
232
|
-
</div>
|
|
233
|
-
|
|
234
228
|
<!-- Counter -->
|
|
235
229
|
<div class="card">
|
|
236
230
|
<h2>Counter</h2>
|
|
@@ -328,7 +322,7 @@
|
|
|
328
322
|
</div>
|
|
329
323
|
|
|
330
324
|
<!-- Debounced Input -->
|
|
331
|
-
<div class="card"
|
|
325
|
+
<div class="card">
|
|
332
326
|
<h2>Debounced Input</h2>
|
|
333
327
|
<p>Type and wait 500ms to see the debounced value update.</p>
|
|
334
328
|
<input placeholder="Search..." @bind="search" @input.debounce:500="debouncedSearch = search">
|
|
@@ -340,13 +334,13 @@
|
|
|
340
334
|
<div class="card" @data="{ tab: 'one' }">
|
|
341
335
|
<h2>Tabs</h2>
|
|
342
336
|
<div class="tabs">
|
|
343
|
-
<button class="tab" :class="{ active: tab
|
|
344
|
-
<button class="tab" :class="{ active: tab
|
|
345
|
-
<button class="tab" :class="{ active: tab
|
|
337
|
+
<button class="tab" :class="{ active: tab == 'one' }" @click="tab = 'one'">Tab 1</button>
|
|
338
|
+
<button class="tab" :class="{ active: tab == 'two' }" @click="tab = 'two'">Tab 2</button>
|
|
339
|
+
<button class="tab" :class="{ active: tab == 'three' }" @click="tab = 'three'">Tab 3</button>
|
|
346
340
|
</div>
|
|
347
|
-
<div class="tab-content" @visible="tab
|
|
348
|
-
<div class="tab-content" @visible="tab
|
|
349
|
-
<div class="tab-content" @visible="tab
|
|
341
|
+
<div class="tab-content" @visible="tab == 'one'">Content for Tab 1</div>
|
|
342
|
+
<div class="tab-content" @visible="tab == 'two'">Content for Tab 2</div>
|
|
343
|
+
<div class="tab-content" @visible="tab == 'three'">Content for Tab 3</div>
|
|
350
344
|
</div>
|
|
351
345
|
|
|
352
346
|
<!-- Element References -->
|
|
@@ -395,7 +389,7 @@
|
|
|
395
389
|
</div>
|
|
396
390
|
|
|
397
391
|
<!-- Character Counter -->
|
|
398
|
-
<div class="card" @data="{
|
|
392
|
+
<div class="card" @data="{ maxLength: 100 }">
|
|
399
393
|
<h2>Character Counter</h2>
|
|
400
394
|
<textarea @bind="text" placeholder="Type something..." rows="3"></textarea>
|
|
401
395
|
<span @calculate:remaining="maxLength - text.length"></span>
|
|
@@ -405,7 +399,7 @@
|
|
|
405
399
|
</div>
|
|
406
400
|
|
|
407
401
|
<!-- Password Strength (uses @import) -->
|
|
408
|
-
<div class="card" @
|
|
402
|
+
<div class="card" @import="https://codepen.io/daz4126/pen/xbOzGNO.js">
|
|
409
403
|
<h2>Password Strength</h2>
|
|
410
404
|
<input type="password" @bind="password" placeholder="Enter password">
|
|
411
405
|
<span @calculate:result="checkStrength(password)"></span>
|
|
@@ -418,7 +412,7 @@
|
|
|
418
412
|
</div>
|
|
419
413
|
|
|
420
414
|
<!-- Init Example -->
|
|
421
|
-
<div class="card"
|
|
415
|
+
<div class="card">
|
|
422
416
|
<h2>Init Example</h2>
|
|
423
417
|
<p @init="loadTime = new Date().toLocaleTimeString()">
|
|
424
418
|
Page loaded at: <b @text="loadTime"></b>
|
|
@@ -427,7 +421,7 @@
|
|
|
427
421
|
</div>
|
|
428
422
|
|
|
429
423
|
<!-- Effect Example -->
|
|
430
|
-
<div class="card" @data="{
|
|
424
|
+
<div class="card" @data="{ history: [] }">
|
|
431
425
|
<h2>Effect (Side Effects)</h2>
|
|
432
426
|
<p>Click the button and watch the history build up.</p>
|
|
433
427
|
<button @click="clicks++">Click Me</button>
|
|
@@ -436,21 +430,12 @@
|
|
|
436
430
|
<p>History: <span @text="history.join(', ') || 'None yet'">None yet</span></p>
|
|
437
431
|
</div>
|
|
438
432
|
|
|
439
|
-
<!-- Clipboard Copy -->
|
|
440
|
-
<div class="card" @data="{ copied: false, textToCopy: 'Hello from Helium!' }">
|
|
441
|
-
<h2>Copy to Clipboard</h2>
|
|
442
|
-
<input @bind="textToCopy" placeholder="Text to copy">
|
|
443
|
-
<button @click="navigator.clipboard.writeText(textToCopy).then(() => { copied = true; setTimeout(() => copied = false, 2000) })">
|
|
444
|
-
<span @text="copied ? 'Copied!' : 'Copy'"></span>
|
|
445
|
-
</button>
|
|
446
|
-
</div>
|
|
447
|
-
|
|
448
433
|
</div>
|
|
449
434
|
|
|
450
435
|
<!-- AJAX Examples Section -->
|
|
451
436
|
<div class="page" style="margin-top: 2rem;" @data="{
|
|
452
437
|
templates: {
|
|
453
|
-
post: data => `<section class='post'><h1>${data
|
|
438
|
+
post: data => `<section class='post'><h1>${data?.title || 'Loading...'}</h1><p>${data?.body || ''}</p></section>`
|
|
454
439
|
}
|
|
455
440
|
}">
|
|
456
441
|
<h1>AJAX Examples</h1>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2
|
+
body {
|
|
3
|
+
font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
4
|
+
background: #f5f5f5;
|
|
5
|
+
min-height: 100vh;
|
|
6
|
+
}
|
|
7
|
+
.todoapp {
|
|
8
|
+
max-width: 550px;
|
|
9
|
+
margin: 40px auto;
|
|
10
|
+
background: #fff;
|
|
11
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.2), 0 25px 50px rgba(0,0,0,0.1);
|
|
12
|
+
}
|
|
13
|
+
.todoapp h1 {
|
|
14
|
+
font-size: 80px;
|
|
15
|
+
font-weight: 200;
|
|
16
|
+
text-align: center;
|
|
17
|
+
color: #b83f45;
|
|
18
|
+
padding: 10px 0;
|
|
19
|
+
}
|
|
20
|
+
.new-todo {
|
|
21
|
+
width: 100%;
|
|
22
|
+
padding: 16px 16px 16px 20px;
|
|
23
|
+
font-size: 24px;
|
|
24
|
+
border: none;
|
|
25
|
+
border-bottom: 1px solid #ededed;
|
|
26
|
+
font-family: inherit;
|
|
27
|
+
outline: none;
|
|
28
|
+
}
|
|
29
|
+
.new-todo::placeholder { color: #d9d9d9; font-style: italic; }
|
|
30
|
+
.toggle-all-container {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
padding: 12px 15px;
|
|
34
|
+
border-bottom: 1px solid #ededed;
|
|
35
|
+
color: #777;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
}
|
|
38
|
+
.toggle-all-container input { margin-right: 10px; }
|
|
39
|
+
.todo-list { list-style: none; }
|
|
40
|
+
.todo-item {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
padding: 15px;
|
|
44
|
+
border-bottom: 1px solid #ededed;
|
|
45
|
+
font-size: 24px;
|
|
46
|
+
}
|
|
47
|
+
.todo-item.completed .todo-text {
|
|
48
|
+
color: #949494;
|
|
49
|
+
text-decoration: line-through;
|
|
50
|
+
}
|
|
51
|
+
.toggle {
|
|
52
|
+
width: 24px;
|
|
53
|
+
height: 24px;
|
|
54
|
+
margin-right: 15px;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
flex-shrink: 0;
|
|
57
|
+
accent-color: #85cda8;
|
|
58
|
+
}
|
|
59
|
+
.todo-text { flex: 1; word-break: break-all; }
|
|
60
|
+
.destroy {
|
|
61
|
+
display: none;
|
|
62
|
+
background: none;
|
|
63
|
+
border: none;
|
|
64
|
+
color: #cc9a9a;
|
|
65
|
+
font-size: 28px;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
padding: 0 10px;
|
|
68
|
+
line-height: 1;
|
|
69
|
+
}
|
|
70
|
+
.destroy:hover { color: #af5b5e; }
|
|
71
|
+
.todo-item:hover .destroy { display: block; }
|
|
72
|
+
.footer {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
justify-content: space-between;
|
|
76
|
+
padding: 10px 15px;
|
|
77
|
+
color: #777;
|
|
78
|
+
font-size: 14px;
|
|
79
|
+
border-top: 1px solid #e6e6e6;
|
|
80
|
+
}
|
|
81
|
+
.filters { display: flex; gap: 3px; }
|
|
82
|
+
.filters button {
|
|
83
|
+
background: none;
|
|
84
|
+
border: 1px solid transparent;
|
|
85
|
+
padding: 3px 7px;
|
|
86
|
+
border-radius: 3px;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
font-size: 14px;
|
|
89
|
+
color: inherit;
|
|
90
|
+
font-family: inherit;
|
|
91
|
+
}
|
|
92
|
+
.filters button:hover { border-color: #db7676; }
|
|
93
|
+
.filters button.selected { border-color: #ce4646; }
|
|
94
|
+
.clear-completed {
|
|
95
|
+
background: none;
|
|
96
|
+
border: none;
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
color: inherit;
|
|
99
|
+
font-size: 14px;
|
|
100
|
+
font-family: inherit;
|
|
101
|
+
}
|
|
102
|
+
.clear-completed:hover { text-decoration: underline; }
|
|
103
|
+
.info {
|
|
104
|
+
text-align: center;
|
|
105
|
+
margin: 30px 0;
|
|
106
|
+
color: #bbb;
|
|
107
|
+
font-size: 12px;
|
|
108
|
+
}
|
|
109
|
+
.info a { color: inherit; }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Helium TodoMVC</title>
|
|
7
|
+
<link rel="stylesheet" href="todo.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
|
|
11
|
+
<section class="todoapp" data-helium @local-storage="helium-todos" @import="todo"
|
|
12
|
+
@data="{ todos: [], newTodo: '', filter: 'all', nextId: 1 }">
|
|
13
|
+
|
|
14
|
+
<span hidden @calculate:remaining="todos.filter(t => !t.completed).length"></span>
|
|
15
|
+
<span hidden @calculate:filtered="filter === 'all' ? todos : filter === 'active' ? todos.filter(t => !t.completed) : todos.filter(t => t.completed)"></span>
|
|
16
|
+
|
|
17
|
+
<header>
|
|
18
|
+
<h1>todos</h1>
|
|
19
|
+
<input class="new-todo" @bind="newTodo" @keydown.enter="addTodo($data)" placeholder="What needs to be done?" autofocus>
|
|
20
|
+
</header>
|
|
21
|
+
|
|
22
|
+
<section @visible="todos.length > 0">
|
|
23
|
+
<label class="toggle-all-container">
|
|
24
|
+
<input type="checkbox" @click="toggleAll($data)" :checked="todos.length > 0 && todos.every(t => t.completed)">
|
|
25
|
+
Mark all as complete
|
|
26
|
+
</label>
|
|
27
|
+
<ul class="todo-list" @html="renderTodos(filtered)"></ul>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<footer class="footer" @visible="todos.length > 0">
|
|
31
|
+
<span @text="remaining + (remaining === 1 ? ' item' : ' items') + ' left'"></span>
|
|
32
|
+
<nav class="filters">
|
|
33
|
+
<button :class="{ selected: filter === 'all' }" @click="filter = 'all'">All</button>
|
|
34
|
+
<button :class="{ selected: filter === 'active' }" @click="filter = 'active'">Active</button>
|
|
35
|
+
<button :class="{ selected: filter === 'completed' }" @click="filter = 'completed'">Completed</button>
|
|
36
|
+
</nav>
|
|
37
|
+
<button class="clear-completed" @click="clearCompleted($data)" @visible="todos.some(t => t.completed)">Clear completed</button>
|
|
38
|
+
</footer>
|
|
39
|
+
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<p class="info">Built with <a href="https://github.com/daz-codes/helium">Helium</a></p>
|
|
43
|
+
|
|
44
|
+
<script type="module" src="../helium.js"></script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
package/examples/todo.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function esc(s) {
|
|
2
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function renderTodos(list) {
|
|
6
|
+
return list.map(t => `<li class='todo-item${t.completed ? ' completed' : ''}'>
|
|
7
|
+
<input type='checkbox' class='toggle'${t.completed ? ' checked' : ''} @click='toggleTodo($data, ${t.id})'>
|
|
8
|
+
<span class='todo-text'>${esc(t.text)}</span>
|
|
9
|
+
<button class='destroy' @click='removeTodo($data, ${t.id})'>×</button>
|
|
10
|
+
</li>`).join('');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function addTodo($data) {
|
|
14
|
+
const text = $data.newTodo.trim();
|
|
15
|
+
if (text) {
|
|
16
|
+
$data.todos = [...$data.todos, { id: $data.nextId++, text, completed: false }];
|
|
17
|
+
$data.newTodo = '';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toggleTodo($data, id) {
|
|
22
|
+
$data.todos = $data.todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function removeTodo($data, id) {
|
|
26
|
+
$data.todos = $data.todos.filter(t => t.id !== id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toggleAll($data) {
|
|
30
|
+
const allDone = $data.todos.every(t => t.completed);
|
|
31
|
+
$data.todos = $data.todos.map(t => ({ ...t, completed: !allDone }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearCompleted($data) {
|
|
35
|
+
$data.todos = $data.todos.filter(t => !t.completed);
|
|
36
|
+
}
|
package/helium-lite.js
CHANGED
|
@@ -4,19 +4,47 @@
|
|
|
4
4
|
* No AJAX, no @import, no SSE, no Turbo
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const parseEx =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
const parseEx = (v) => {
|
|
8
|
+
try {
|
|
9
|
+
return Function(`return(${v})`)();
|
|
10
|
+
} catch {
|
|
11
|
+
return v;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const tryNum = (v) => {
|
|
15
|
+
const t = String(v).trim(),
|
|
16
|
+
n = +t;
|
|
17
|
+
return t && !isNaN(n) ? n : v;
|
|
18
|
+
};
|
|
19
|
+
const isValidIdentifier = (v) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v);
|
|
20
|
+
const RESERVED = new Set([
|
|
21
|
+
"undefined",
|
|
22
|
+
"null",
|
|
23
|
+
"true",
|
|
24
|
+
"false",
|
|
25
|
+
"NaN",
|
|
26
|
+
"Infinity",
|
|
27
|
+
"this",
|
|
28
|
+
"arguments",
|
|
29
|
+
]);
|
|
30
|
+
const KEY_MODS = {
|
|
31
|
+
shift: "shiftKey",
|
|
32
|
+
ctrl: "ctrlKey",
|
|
33
|
+
alt: "altKey",
|
|
34
|
+
meta: "metaKey",
|
|
35
|
+
};
|
|
36
|
+
const debounce = (f, d) => {
|
|
37
|
+
let t;
|
|
38
|
+
return (...a) => (clearTimeout(t), (t = setTimeout(f, d, ...a)));
|
|
39
|
+
};
|
|
13
40
|
|
|
14
41
|
const ATTR_REGEX = /^(@|:|data-he)/;
|
|
15
42
|
|
|
16
43
|
const match = (name, ...attrs) => {
|
|
17
44
|
const p = name.split(/[.:]/)[0];
|
|
18
|
-
|
|
19
|
-
|
|
45
|
+
return p == ":" || p == ""
|
|
46
|
+
? false
|
|
47
|
+
: attrs.some((a) => p === `@${a}` || p === `data-he-${a}`);
|
|
20
48
|
};
|
|
21
49
|
|
|
22
50
|
let HELIUM = null;
|
|
@@ -24,8 +52,13 @@ let HELIUM = null;
|
|
|
24
52
|
async function helium(initialState = {}) {
|
|
25
53
|
const inits = [];
|
|
26
54
|
const ALL = Symbol("all");
|
|
27
|
-
const root =
|
|
28
|
-
|
|
55
|
+
const root =
|
|
56
|
+
document.querySelector("[\\@helium]") ||
|
|
57
|
+
document.querySelector("[data-helium]") ||
|
|
58
|
+
document.body;
|
|
59
|
+
const storageKey =
|
|
60
|
+
root.getAttribute("@local-storage") ||
|
|
61
|
+
root.getAttribute("data-he-local-storage");
|
|
29
62
|
|
|
30
63
|
if (!HELIUM) {
|
|
31
64
|
HELIUM = {
|
|
@@ -36,12 +69,14 @@ async function helium(initialState = {}) {
|
|
|
36
69
|
processed: new WeakSet(),
|
|
37
70
|
parentKeys: new WeakMap(),
|
|
38
71
|
fnCache: new Map(),
|
|
39
|
-
proxyCache: new WeakMap()
|
|
72
|
+
proxyCache: new WeakMap(),
|
|
40
73
|
};
|
|
41
74
|
}
|
|
42
75
|
|
|
43
|
-
const $ = s => document.querySelector(s);
|
|
44
|
-
const html =
|
|
76
|
+
const $ = (s) => document.querySelector(s);
|
|
77
|
+
const html = (s) =>
|
|
78
|
+
Object.assign(document.createElement("template"), { innerHTML: s.trim() })
|
|
79
|
+
.content.firstChild;
|
|
45
80
|
|
|
46
81
|
const applyingBindings = new Set();
|
|
47
82
|
|
|
@@ -57,9 +92,9 @@ async function helium(initialState = {}) {
|
|
|
57
92
|
|
|
58
93
|
const handler = {
|
|
59
94
|
has(t, p) {
|
|
60
|
-
if (typeof p ===
|
|
95
|
+
if (typeof p === "string" && p.startsWith("$")) return false;
|
|
61
96
|
if (Reflect.has(t, p)) return true;
|
|
62
|
-
if (typeof globalThis[p] !==
|
|
97
|
+
if (typeof globalThis[p] !== "undefined") return false;
|
|
63
98
|
return true;
|
|
64
99
|
},
|
|
65
100
|
get(t, p, r) {
|
|
@@ -81,21 +116,12 @@ async function helium(initialState = {}) {
|
|
|
81
116
|
HELIUM.bindings.get(ALL)?.forEach(safeApplyBinding);
|
|
82
117
|
if (storageKey) localStorage.setItem(storageKey, JSON.stringify(state));
|
|
83
118
|
return res;
|
|
84
|
-
}
|
|
119
|
+
},
|
|
85
120
|
};
|
|
86
121
|
|
|
87
122
|
const state = new Proxy({}, handler);
|
|
88
|
-
if (storageKey) try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey))); } catch {}
|
|
89
123
|
Object.assign(state, initialState);
|
|
90
124
|
|
|
91
|
-
const getRefsObject = () => {
|
|
92
|
-
const refs = {};
|
|
93
|
-
for (const [name, element] of HELIUM.refs) {
|
|
94
|
-
refs[name] = element;
|
|
95
|
-
}
|
|
96
|
-
return refs;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
125
|
const createScope = (el, event = {}) => {
|
|
100
126
|
return {
|
|
101
127
|
$,
|
|
@@ -103,7 +129,7 @@ async function helium(initialState = {}) {
|
|
|
103
129
|
$event: event,
|
|
104
130
|
$el: el,
|
|
105
131
|
$html: html,
|
|
106
|
-
...
|
|
132
|
+
...Object.fromEntries(HELIUM.refs),
|
|
107
133
|
};
|
|
108
134
|
};
|
|
109
135
|
|
|
@@ -115,7 +141,7 @@ async function helium(initialState = {}) {
|
|
|
115
141
|
"$scope",
|
|
116
142
|
withReturn
|
|
117
143
|
? `with($scope){with($scope.$data){return(${expr.trim()})}}`
|
|
118
|
-
: `with($scope){with($scope.$data){${expr.trim()}}}
|
|
144
|
+
: `with($scope){with($scope.$data){${expr.trim()}}}`,
|
|
119
145
|
);
|
|
120
146
|
const compiled = { execute: (scope) => fn(scope), getIds: null };
|
|
121
147
|
HELIUM.fnCache.set(key, compiled);
|
|
@@ -136,18 +162,22 @@ async function helium(initialState = {}) {
|
|
|
136
162
|
if (prop === "innerHTML") {
|
|
137
163
|
const content = Array.isArray(r) ? r.join`` : r;
|
|
138
164
|
return typeof Idiomorph === "object"
|
|
139
|
-
? Idiomorph.morph(el, content, { morphStyle:
|
|
140
|
-
: el.innerHTML = content;
|
|
165
|
+
? Idiomorph.morph(el, content, { morphStyle: "innerHTML" })
|
|
166
|
+
: (el.innerHTML = content);
|
|
141
167
|
}
|
|
142
168
|
if (prop === "class" && r && typeof r === "object")
|
|
143
169
|
return Object.entries(r).forEach(([k, v]) =>
|
|
144
|
-
k.split(/\s+/).forEach(c => el.classList.toggle(c, v))
|
|
170
|
+
k.split(/\s+/).forEach((c) => el.classList.toggle(c, v)),
|
|
171
|
+
);
|
|
145
172
|
|
|
146
173
|
if (prop === "style" && r && typeof r === "object")
|
|
147
|
-
return el.style.cssText = Object.entries(r)
|
|
174
|
+
return (el.style.cssText = Object.entries(r)
|
|
148
175
|
.filter(([_, v]) => v != null && v !== false)
|
|
149
|
-
.map(
|
|
150
|
-
|
|
176
|
+
.map(
|
|
177
|
+
([k, v]) =>
|
|
178
|
+
`${k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}:${v}`,
|
|
179
|
+
)
|
|
180
|
+
.join("; "));
|
|
151
181
|
|
|
152
182
|
if (prop === "hidden") {
|
|
153
183
|
el.hidden = isHiddenAttr ? !!r : !r;
|
|
@@ -156,8 +186,9 @@ async function helium(initialState = {}) {
|
|
|
156
186
|
|
|
157
187
|
if (prop in el) {
|
|
158
188
|
if (isRadio && prop !== "checked") el.checked = el.value === r;
|
|
159
|
-
else if (el.type === "radio" && prop !== "checked")
|
|
160
|
-
|
|
189
|
+
else if (el.type === "radio" && prop !== "checked")
|
|
190
|
+
el.checked = el.value === r;
|
|
191
|
+
else el[prop] = prop === "textContent" ? (r ?? "") : parseEx(r);
|
|
161
192
|
return;
|
|
162
193
|
}
|
|
163
194
|
|
|
@@ -171,12 +202,16 @@ async function helium(initialState = {}) {
|
|
|
171
202
|
return true;
|
|
172
203
|
},
|
|
173
204
|
get(target, prop) {
|
|
174
|
-
if (typeof prop ==
|
|
175
|
-
excludeChanged
|
|
205
|
+
if (typeof prop == "string") {
|
|
206
|
+
excludeChanged
|
|
207
|
+
? !accessed.has(prop) && accessed.set(prop, target[prop])
|
|
208
|
+
: accessed.add(prop);
|
|
176
209
|
}
|
|
177
210
|
const val = target[prop];
|
|
178
|
-
return typeof val == "object" && val != null
|
|
179
|
-
|
|
211
|
+
return typeof val == "object" && val != null
|
|
212
|
+
? new Proxy(val, this)
|
|
213
|
+
: val;
|
|
214
|
+
},
|
|
180
215
|
});
|
|
181
216
|
|
|
182
217
|
try {
|
|
@@ -186,21 +221,29 @@ async function helium(initialState = {}) {
|
|
|
186
221
|
$event: {},
|
|
187
222
|
$el: el,
|
|
188
223
|
$html: html,
|
|
189
|
-
...
|
|
224
|
+
...Object.fromEntries(HELIUM.refs),
|
|
190
225
|
};
|
|
191
226
|
compiled.execute(scope);
|
|
192
227
|
} catch {}
|
|
193
228
|
|
|
194
|
-
return excludeChanged
|
|
229
|
+
return excludeChanged
|
|
230
|
+
? [...accessed.keys()].filter(
|
|
231
|
+
(prop) => state[prop] === accessed.get(prop),
|
|
232
|
+
)
|
|
233
|
+
: [...accessed];
|
|
195
234
|
};
|
|
196
235
|
|
|
197
236
|
const getDependencies = (compiled, el, excludeChanged = false) => {
|
|
198
237
|
return trackDependenciesProxy(compiled, el, excludeChanged);
|
|
199
238
|
};
|
|
200
239
|
|
|
201
|
-
const cleanup = el => {
|
|
202
|
-
[el, ...el.querySelectorAll(
|
|
203
|
-
HELIUM.listeners
|
|
240
|
+
const cleanup = (el) => {
|
|
241
|
+
[el, ...el.querySelectorAll("*")].forEach((e) => {
|
|
242
|
+
HELIUM.listeners
|
|
243
|
+
.get(e)
|
|
244
|
+
?.forEach(({ receiver, event, handler }) =>
|
|
245
|
+
receiver.removeEventListener(event, handler),
|
|
246
|
+
);
|
|
204
247
|
HELIUM.listeners.delete(e);
|
|
205
248
|
});
|
|
206
249
|
};
|
|
@@ -209,91 +252,154 @@ async function helium(initialState = {}) {
|
|
|
209
252
|
const newBindings = [];
|
|
210
253
|
const deferredBindings = [];
|
|
211
254
|
|
|
212
|
-
const heElements = [element, ...element.querySelectorAll("*")].filter(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
255
|
+
const heElements = [element, ...element.querySelectorAll("*")].filter(
|
|
256
|
+
(e) => {
|
|
257
|
+
if (HELIUM.processed.has(e)) return false;
|
|
258
|
+
for (let i = 0; i < e.attributes.length; i++) {
|
|
259
|
+
if (ATTR_REGEX.test(e.attributes[i].name)) return true;
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
},
|
|
263
|
+
);
|
|
219
264
|
|
|
220
265
|
const addBinding = (val, b) => {
|
|
221
|
-
HELIUM.bindings.set(
|
|
266
|
+
HELIUM.bindings.set(
|
|
267
|
+
val,
|
|
268
|
+
b.calc
|
|
269
|
+
? [b, ...(HELIUM.bindings.get(val) || [])]
|
|
270
|
+
: [...(HELIUM.bindings.get(val) || []), b],
|
|
271
|
+
);
|
|
222
272
|
b.calc ? newBindings.unshift(b) : newBindings.push(b);
|
|
223
273
|
};
|
|
224
274
|
|
|
225
|
-
heElements.forEach(el => {
|
|
275
|
+
heElements.forEach((el) => {
|
|
226
276
|
HELIUM.processed.add(el);
|
|
227
277
|
|
|
228
278
|
const attrs = el.attributes;
|
|
229
279
|
const inputType = el.type?.toLowerCase();
|
|
230
|
-
const isCheckbox = inputType == "checkbox",
|
|
280
|
+
const isCheckbox = inputType == "checkbox",
|
|
281
|
+
isRadio = inputType == "radio",
|
|
282
|
+
isSelect = el.tagName == "SELECT";
|
|
231
283
|
|
|
232
284
|
for (let i = 0; i < attrs.length; i++) {
|
|
233
285
|
const { name, value } = attrs[i];
|
|
234
286
|
|
|
235
|
-
if (
|
|
287
|
+
if (
|
|
288
|
+
!name.startsWith("@") &&
|
|
289
|
+
!name.startsWith(":") &&
|
|
290
|
+
!name.startsWith("data-he")
|
|
291
|
+
)
|
|
292
|
+
continue;
|
|
236
293
|
|
|
237
294
|
// Initialize state first if needed (skip reserved words, only if not already set)
|
|
238
|
-
if (
|
|
239
|
-
|
|
295
|
+
if (
|
|
296
|
+
match(name, "text", "html", "bind") &&
|
|
297
|
+
isValidIdentifier(value) &&
|
|
298
|
+
!RESERVED.has(value) &&
|
|
299
|
+
state[value] == null
|
|
300
|
+
) {
|
|
301
|
+
state[value] = match(name, "bind")
|
|
302
|
+
? el.type == "checkbox"
|
|
303
|
+
? el.checked
|
|
304
|
+
: tryNum(el.value)
|
|
305
|
+
: tryNum(el.textContent);
|
|
240
306
|
}
|
|
241
307
|
|
|
242
308
|
// Process the attribute
|
|
243
309
|
if (name === "@data" || name === "data-he") {
|
|
244
310
|
Object.assign(state, parseEx(value));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
311
|
+
} else if (name.startsWith(":") || name.startsWith("data-he-attr:")) {
|
|
312
|
+
const propName = name.startsWith(":")
|
|
313
|
+
? name.slice(1)
|
|
314
|
+
: name.slice(13);
|
|
315
|
+
deferredBindings.push({
|
|
316
|
+
el,
|
|
317
|
+
prop: propName,
|
|
318
|
+
compiled: compile(value, true),
|
|
319
|
+
});
|
|
320
|
+
} else if (match(name, "ref")) {
|
|
251
321
|
HELIUM.refs.set("$" + value, el);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
322
|
+
} else if (match(name, "text", "html")) {
|
|
323
|
+
deferredBindings.push({
|
|
324
|
+
el,
|
|
325
|
+
prop: match(name, "text") ? "textContent" : "innerHTML",
|
|
326
|
+
compiled: compile(value, true),
|
|
327
|
+
});
|
|
328
|
+
} else if (match(name, "bind")) {
|
|
329
|
+
const event = isCheckbox || isRadio || isSelect ? "change" : "input";
|
|
258
330
|
const prop = isCheckbox ? "checked" : "value";
|
|
259
|
-
const inputHandler = e =>
|
|
331
|
+
const inputHandler = (e) =>
|
|
332
|
+
(state[value] = isCheckbox ? e.target.checked : e.target.value);
|
|
260
333
|
el.addEventListener(event, inputHandler);
|
|
261
334
|
if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
|
|
262
|
-
HELIUM.listeners
|
|
263
|
-
|
|
335
|
+
HELIUM.listeners
|
|
336
|
+
.get(el)
|
|
337
|
+
.push({ receiver: el, event, handler: inputHandler });
|
|
338
|
+
deferredBindings.push({
|
|
339
|
+
el,
|
|
340
|
+
prop,
|
|
341
|
+
compiled: compile(value, true),
|
|
342
|
+
isRadio,
|
|
343
|
+
});
|
|
264
344
|
if (isCheckbox) el.checked = !!state[value];
|
|
265
345
|
else if (isRadio) el.checked = el.value == state[value];
|
|
266
346
|
else el.value = state[value] ?? "";
|
|
267
|
-
}
|
|
268
|
-
else if (match(name, "hidden", "visible")) {
|
|
347
|
+
} else if (match(name, "hidden", "visible")) {
|
|
269
348
|
const isHidden = match(name, "hidden");
|
|
270
|
-
deferredBindings.push({
|
|
271
|
-
|
|
272
|
-
|
|
349
|
+
deferredBindings.push({
|
|
350
|
+
el,
|
|
351
|
+
prop: "hidden",
|
|
352
|
+
compiled: compile(value, true),
|
|
353
|
+
isHiddenAttr: isHidden,
|
|
354
|
+
});
|
|
355
|
+
} else if (match(name, "calculate")) {
|
|
273
356
|
const calcName = name.split(":")[1];
|
|
274
357
|
if (!(calcName in state)) state[calcName] = undefined;
|
|
275
|
-
deferredBindings.push({
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
358
|
+
deferredBindings.push({
|
|
359
|
+
el,
|
|
360
|
+
calc: calcName,
|
|
361
|
+
prop: null,
|
|
362
|
+
compiled: compile(value, true),
|
|
363
|
+
});
|
|
364
|
+
} else if (match(name, "effect")) {
|
|
365
|
+
deferredBindings.push({
|
|
366
|
+
el,
|
|
367
|
+
prop: null,
|
|
368
|
+
compiled: compile(value, true),
|
|
369
|
+
keys: name.split(":").slice(1),
|
|
370
|
+
});
|
|
371
|
+
} else if (match(name, "init")) {
|
|
281
372
|
inits.push({ compiled: compile(value, true), el });
|
|
282
|
-
}
|
|
283
|
-
else if (name.startsWith("@") || name.startsWith("data-he")) {
|
|
373
|
+
} else if (name.startsWith("@") || name.startsWith("data-he")) {
|
|
284
374
|
const fullName = name.startsWith("@") ? name.slice(1) : name.slice(8);
|
|
285
375
|
const [event, ...mods] = fullName.split(".");
|
|
286
|
-
const receiver =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
376
|
+
const receiver =
|
|
377
|
+
mods.includes("outside") || mods.includes("document")
|
|
378
|
+
? document
|
|
379
|
+
: el;
|
|
380
|
+
const debounceMod = mods.find((m) => m.startsWith("debounce"));
|
|
381
|
+
const debounceDelay = debounceMod
|
|
382
|
+
? ((t) => (t && !isNaN(t) ? Number(t) : 300))(
|
|
383
|
+
debounceMod.split(":")[1],
|
|
384
|
+
)
|
|
385
|
+
: 0;
|
|
386
|
+
const _handler = (e) => {
|
|
290
387
|
if (mods.includes("prevent")) e.preventDefault();
|
|
291
388
|
if (mods.includes("stop")) e.stopPropagation();
|
|
292
|
-
for (const [mod, prop] of Object.entries(KEY_MODS))
|
|
389
|
+
for (const [mod, prop] of Object.entries(KEY_MODS))
|
|
390
|
+
if (mods.includes(mod) && !e[prop]) return;
|
|
293
391
|
if (["keydown", "keyup", "keypress"].includes(event)) {
|
|
294
392
|
const last = mods[mods.length - 1];
|
|
295
|
-
if (
|
|
296
|
-
|
|
393
|
+
if (
|
|
394
|
+
last &&
|
|
395
|
+
!["prevent", "once", "outside", "document", "stop"].includes(
|
|
396
|
+
last,
|
|
397
|
+
) &&
|
|
398
|
+
!last.startsWith("debounce") &&
|
|
399
|
+
!Object.keys(KEY_MODS).includes(last)
|
|
400
|
+
) {
|
|
401
|
+
const keyName =
|
|
402
|
+
e.key == " " ? "Space" : e.key == "Escape" ? "Esc" : e.key;
|
|
297
403
|
if (keyName.toLowerCase() !== last.toLowerCase()) return;
|
|
298
404
|
}
|
|
299
405
|
}
|
|
@@ -302,10 +408,14 @@ async function helium(initialState = {}) {
|
|
|
302
408
|
const compiled = compile(value, true);
|
|
303
409
|
compiled.execute(scope);
|
|
304
410
|
}
|
|
305
|
-
if (mods.includes("once"))
|
|
411
|
+
if (mods.includes("once"))
|
|
412
|
+
receiver.removeEventListener(event, handler);
|
|
306
413
|
};
|
|
307
|
-
const handler =
|
|
308
|
-
|
|
414
|
+
const handler =
|
|
415
|
+
debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
|
|
416
|
+
const eventOptions = mods.includes("once")
|
|
417
|
+
? { once: true }
|
|
418
|
+
: undefined;
|
|
309
419
|
receiver.addEventListener(event, handler, eventOptions);
|
|
310
420
|
if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
|
|
311
421
|
HELIUM.listeners.get(el).push({ receiver, event, handler });
|
|
@@ -313,9 +423,11 @@ async function helium(initialState = {}) {
|
|
|
313
423
|
}
|
|
314
424
|
});
|
|
315
425
|
|
|
316
|
-
deferredBindings.forEach(b => {
|
|
317
|
-
const tracked = b.keys?.includes("*")
|
|
318
|
-
|
|
426
|
+
deferredBindings.forEach((b) => {
|
|
427
|
+
const tracked = b.keys?.includes("*")
|
|
428
|
+
? [ALL]
|
|
429
|
+
: getDependencies(b.compiled, b.el, true).concat(b.keys || []);
|
|
430
|
+
tracked.forEach((key) => addBinding(key, b));
|
|
319
431
|
if (tracked.length === 0) {
|
|
320
432
|
newBindings.push(b);
|
|
321
433
|
}
|
|
@@ -339,7 +451,11 @@ async function helium(initialState = {}) {
|
|
|
339
451
|
});
|
|
340
452
|
HELIUM.observer.observe(root, { childList: true, subtree: true });
|
|
341
453
|
|
|
454
|
+
// Read localStorage before processElements (which sets @data defaults and triggers saves via proxy)
|
|
455
|
+
const savedState = storageKey ? (() => { try { return JSON.parse(localStorage.getItem(storageKey)); } catch { return null; } })() : null;
|
|
342
456
|
const initialBindings = await processElements(root);
|
|
457
|
+
// Merge saved values after @data defaults, so localStorage takes priority
|
|
458
|
+
if (savedState) Object.assign(state, savedState);
|
|
343
459
|
initialBindings.forEach(applyBinding);
|
|
344
460
|
inits.forEach(({ compiled, el }) => {
|
|
345
461
|
const scope = createScope(el);
|
|
@@ -355,13 +471,13 @@ function heliumTeardown() {
|
|
|
355
471
|
}
|
|
356
472
|
|
|
357
473
|
// Browser globals
|
|
358
|
-
if (typeof window !==
|
|
474
|
+
if (typeof window !== "undefined") {
|
|
359
475
|
window.helium = helium;
|
|
360
476
|
window.heliumTeardown = heliumTeardown;
|
|
361
477
|
}
|
|
362
478
|
|
|
363
479
|
// Auto-initialize
|
|
364
|
-
if (typeof document !==
|
|
480
|
+
if (typeof document !== "undefined") {
|
|
365
481
|
document.addEventListener("DOMContentLoaded", () => helium());
|
|
366
482
|
}
|
|
367
483
|
|
package/helium.js
CHANGED
|
@@ -238,21 +238,9 @@ export function createHelium(options = {}) {
|
|
|
238
238
|
// Initialize state if it doesn't exist
|
|
239
239
|
const state = new Proxy({}, handler);
|
|
240
240
|
|
|
241
|
-
// Load from localStorage if key exists
|
|
242
|
-
if (storageKey) try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey))); } catch {}
|
|
243
|
-
|
|
244
241
|
// Merge initial state
|
|
245
242
|
Object.assign(state, initialState);
|
|
246
243
|
|
|
247
|
-
// Build refs object for scope
|
|
248
|
-
const getRefsObject = () => {
|
|
249
|
-
const refs = {};
|
|
250
|
-
for (const [name, element] of HELIUM.refs) {
|
|
251
|
-
refs[name] = element;
|
|
252
|
-
}
|
|
253
|
-
return refs;
|
|
254
|
-
};
|
|
255
|
-
|
|
256
244
|
// Create scope for expression execution
|
|
257
245
|
const createScope = (el, event = {}) => {
|
|
258
246
|
return engine.createScope({
|
|
@@ -266,7 +254,7 @@ export function createHelium(options = {}) {
|
|
|
266
254
|
put,
|
|
267
255
|
patch,
|
|
268
256
|
del,
|
|
269
|
-
refs:
|
|
257
|
+
refs: Object.fromEntries(HELIUM.refs)
|
|
270
258
|
});
|
|
271
259
|
};
|
|
272
260
|
|
|
@@ -349,7 +337,7 @@ export function createHelium(options = {}) {
|
|
|
349
337
|
put,
|
|
350
338
|
patch,
|
|
351
339
|
del,
|
|
352
|
-
refs:
|
|
340
|
+
refs: Object.fromEntries(HELIUM.refs)
|
|
353
341
|
});
|
|
354
342
|
compiled.execute(scope);
|
|
355
343
|
} catch {}
|
|
@@ -404,9 +392,7 @@ export function createHelium(options = {}) {
|
|
|
404
392
|
const hasExtension = moduleName.endsWith(".js");
|
|
405
393
|
const hasPathPrefix = moduleName.startsWith("/") || moduleName.startsWith("./") || moduleName.startsWith("../");
|
|
406
394
|
const relativePath = (isUrl || hasPathPrefix ? "" : "./") + moduleName + (isUrl || hasExtension ? "" : ".js");
|
|
407
|
-
|
|
408
|
-
const baseUrl = location.href.endsWith('/') || location.href.includes('.html') ? location.href : location.href + '/';
|
|
409
|
-
const path = isUrl ? relativePath : new URL(relativePath, baseUrl).href;
|
|
395
|
+
const path = isUrl ? moduleName : new URL(relativePath, location.href).href;
|
|
410
396
|
importPromises.push(
|
|
411
397
|
import(path)
|
|
412
398
|
.then((module) => {
|
|
@@ -580,7 +566,11 @@ export function createHelium(options = {}) {
|
|
|
580
566
|
});
|
|
581
567
|
HELIUM.observer.observe(root, { childList: true, subtree: true });
|
|
582
568
|
|
|
569
|
+
// Read localStorage before processElements (which sets @data defaults and triggers saves via proxy)
|
|
570
|
+
const savedState = storageKey ? (() => { try { return JSON.parse(localStorage.getItem(storageKey)); } catch { return null; } })() : null;
|
|
583
571
|
const initialBindings = await processElements(root);
|
|
572
|
+
// Merge saved values after @data defaults, so localStorage takes priority
|
|
573
|
+
if (savedState) Object.assign(state, savedState);
|
|
584
574
|
initialBindings.forEach(applyBinding);
|
|
585
575
|
inits.forEach(({ compiled, el }) => {
|
|
586
576
|
const scope = createScope(el);
|
package/helium.test.js
CHANGED
|
@@ -788,4 +788,83 @@ describe('@calculate', () => {
|
|
|
788
788
|
await new Promise(r => setTimeout(r, 10));
|
|
789
789
|
expect(container.querySelector('#result').textContent).toBe('Strong');
|
|
790
790
|
});
|
|
791
|
+
|
|
792
|
+
describe('@local-storage', () => {
|
|
793
|
+
afterEach(() => {
|
|
794
|
+
localStorage.clear();
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('should not overwrite localStorage values with @data defaults', async () => {
|
|
798
|
+
// Simulate saved state from a previous session
|
|
799
|
+
localStorage.setItem('test-todos', JSON.stringify({ todos: ['buy milk', 'walk dog'], filter: 'active', nextId: 3 }));
|
|
800
|
+
|
|
801
|
+
container.setAttribute('data-he-local-storage', 'test-todos');
|
|
802
|
+
container.innerHTML = `
|
|
803
|
+
<div @data="{ todos: [], filter: 'all', nextId: 1 }">
|
|
804
|
+
<span id="count" @text="todos.length"></span>
|
|
805
|
+
<span id="filter" @text="filter"></span>
|
|
806
|
+
<span id="nextId" @text="nextId"></span>
|
|
807
|
+
</div>
|
|
808
|
+
`;
|
|
809
|
+
await helium();
|
|
810
|
+
await new Promise(r => setTimeout(r, 10));
|
|
811
|
+
|
|
812
|
+
// localStorage values should be preserved, not overwritten by @data defaults
|
|
813
|
+
expect(container.querySelector('#count').textContent).toBe('2');
|
|
814
|
+
expect(container.querySelector('#filter').textContent).toBe('active');
|
|
815
|
+
expect(container.querySelector('#nextId').textContent).toBe('3');
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it('should set @data values that are not in localStorage', async () => {
|
|
819
|
+
// Saved state only has some keys
|
|
820
|
+
localStorage.setItem('test-partial', JSON.stringify({ count: 42 }));
|
|
821
|
+
|
|
822
|
+
container.setAttribute('data-he-local-storage', 'test-partial');
|
|
823
|
+
container.innerHTML = `
|
|
824
|
+
<div @data="{ count: 0, name: 'default' }">
|
|
825
|
+
<span id="count" @text="count"></span>
|
|
826
|
+
<span id="name" @text="name"></span>
|
|
827
|
+
</div>
|
|
828
|
+
`;
|
|
829
|
+
await helium();
|
|
830
|
+
await new Promise(r => setTimeout(r, 10));
|
|
831
|
+
|
|
832
|
+
// count should come from localStorage, name should come from @data
|
|
833
|
+
expect(container.querySelector('#count').textContent).toBe('42');
|
|
834
|
+
expect(container.querySelector('#name').textContent).toBe('default');
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('should save state changes to localStorage', async () => {
|
|
838
|
+
container.setAttribute('data-he-local-storage', 'test-save');
|
|
839
|
+
container.innerHTML = `
|
|
840
|
+
<div @data="{ count: 0 }">
|
|
841
|
+
<button @click="count++">Inc</button>
|
|
842
|
+
<span @text="count"></span>
|
|
843
|
+
</div>
|
|
844
|
+
`;
|
|
845
|
+
await helium();
|
|
846
|
+
|
|
847
|
+
container.querySelector('button').click();
|
|
848
|
+
await new Promise(r => setTimeout(r, 10));
|
|
849
|
+
|
|
850
|
+
const saved = JSON.parse(localStorage.getItem('test-save'));
|
|
851
|
+
expect(saved.count).toBe(1);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('should preserve functions from @data when localStorage is active', async () => {
|
|
855
|
+
localStorage.setItem('test-funcs', JSON.stringify({ count: 5 }));
|
|
856
|
+
|
|
857
|
+
container.setAttribute('data-he-local-storage', 'test-funcs');
|
|
858
|
+
container.innerHTML = `
|
|
859
|
+
<div @data="{ count: 0, double(n) { return n * 2 } }">
|
|
860
|
+
<span id="result" @text="double(count)"></span>
|
|
861
|
+
</div>
|
|
862
|
+
`;
|
|
863
|
+
await helium();
|
|
864
|
+
await new Promise(r => setTimeout(r, 10));
|
|
865
|
+
|
|
866
|
+
// count from localStorage (5), function from @data
|
|
867
|
+
expect(container.querySelector('#result').textContent).toBe('10');
|
|
868
|
+
});
|
|
869
|
+
});
|
|
791
870
|
});
|