lume-js 0.2.1 โ 0.3.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 +413 -24
- package/package.json +40 -3
- package/src/addons/computed.js +18 -3
- package/src/addons/watch.js +4 -3
- package/src/core/bindDom.js +94 -16
- package/src/core/state.js +41 -12
- package/src/core/utils.js +33 -6
- package/src/index.d.ts +88 -0
package/README.md
CHANGED
|
@@ -1,51 +1,440 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Lume.js
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Inspired by Go-style simplicity.
|
|
3
|
+
**Reactivity that follows web standards.**
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
5
|
+
Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required, no framework lock-in.
|
|
6
|
+
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](package.json)
|
|
9
|
+
|
|
10
|
+
## Why Lume.js?
|
|
11
|
+
|
|
12
|
+
- ๐ฏ **Standards-Only** - Uses only `data-*` attributes and standard JavaScript
|
|
13
|
+
- ๐ฆ **Tiny** - <2KB gzipped
|
|
14
|
+
- โก **Fast** - Direct DOM updates, no virtual DOM overhead
|
|
15
|
+
- ๐ง **No Build Step** - Works directly in the browser
|
|
16
|
+
- ๐จ **No Lock-in** - Works with any library (GSAP, jQuery, D3, etc.)
|
|
17
|
+
- โฟ **Accessible** - HTML validators love it, screen readers work perfectly
|
|
18
|
+
- ๐งน **Clean API** - Cleanup functions prevent memory leaks
|
|
19
|
+
|
|
20
|
+
### vs Other Libraries
|
|
21
|
+
|
|
22
|
+
| Feature | Lume.js | Alpine.js | Vue | React |
|
|
23
|
+
|---------|---------|-----------|-----|-------|
|
|
24
|
+
| Custom Syntax | โ No | โ
`x-data`, `@click` | โ
`v-bind`, `v-model` | โ
JSX |
|
|
25
|
+
| Build Step | โ Optional | โ Optional | โ ๏ธ Recommended | โ
Required |
|
|
26
|
+
| Bundle Size | ~2KB | ~15KB | ~35KB | ~45KB |
|
|
27
|
+
| HTML Validation | โ
Pass | โ ๏ธ Warnings | โ ๏ธ Warnings | โ JSX |
|
|
28
|
+
| Cleanup API | โ
Yes | โ ๏ธ Limited | โ
Yes | โ
Yes |
|
|
29
|
+
|
|
30
|
+
**Lume.js is essentially "Modern Knockout.js" - standards-only reactivity for 2025.**
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
11
35
|
|
|
12
|
-
|
|
36
|
+
### Via npm
|
|
13
37
|
|
|
14
38
|
```bash
|
|
15
39
|
npm install lume-js
|
|
16
40
|
```
|
|
17
41
|
|
|
18
|
-
|
|
42
|
+
### Via CDN
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<script type="module">
|
|
46
|
+
import { state, bindDom } from 'https://cdn.jsdelivr.net/npm/lume-js/src/index.js';
|
|
47
|
+
</script>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
**HTML:**
|
|
55
|
+
```html
|
|
56
|
+
<div>
|
|
57
|
+
<p>Count: <span data-bind="count"></span></p>
|
|
58
|
+
<input data-bind="name" placeholder="Enter name">
|
|
59
|
+
<p>Hello, <span data-bind="name"></span>!</p>
|
|
60
|
+
|
|
61
|
+
<button id="increment">+</button>
|
|
62
|
+
</div>
|
|
63
|
+
```
|
|
19
64
|
|
|
20
|
-
**
|
|
21
|
-
```
|
|
22
|
-
import { state, bindDom } from
|
|
65
|
+
**JavaScript:**
|
|
66
|
+
```javascript
|
|
67
|
+
import { state, bindDom } from 'lume-js';
|
|
23
68
|
|
|
69
|
+
// Create reactive state
|
|
24
70
|
const store = state({
|
|
25
71
|
count: 0,
|
|
26
|
-
|
|
72
|
+
name: 'World'
|
|
27
73
|
});
|
|
28
74
|
|
|
29
|
-
|
|
75
|
+
// Bind to DOM
|
|
76
|
+
const cleanup = bindDom(document.body, store);
|
|
30
77
|
|
|
31
|
-
|
|
78
|
+
// Update state with standard JavaScript
|
|
79
|
+
document.getElementById('increment').addEventListener('click', () => {
|
|
32
80
|
store.count++;
|
|
33
81
|
});
|
|
34
82
|
|
|
35
|
-
|
|
36
|
-
|
|
83
|
+
// Cleanup when done (important!)
|
|
84
|
+
window.addEventListener('beforeunload', () => cleanup());
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
That's it! No build step, no custom syntax, just HTML and JavaScript.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Core API
|
|
92
|
+
|
|
93
|
+
### `state(object)`
|
|
94
|
+
|
|
95
|
+
Creates a reactive state object using Proxy.
|
|
96
|
+
|
|
97
|
+
```javascript
|
|
98
|
+
const store = state({
|
|
99
|
+
count: 0,
|
|
100
|
+
user: state({ // Nested state must be wrapped
|
|
101
|
+
name: 'Alice',
|
|
102
|
+
email: 'alice@example.com'
|
|
103
|
+
})
|
|
37
104
|
});
|
|
105
|
+
|
|
106
|
+
// Update state
|
|
107
|
+
store.count++;
|
|
108
|
+
store.user.name = 'Bob';
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Features:**
|
|
112
|
+
- โ
Validates input (must be plain object)
|
|
113
|
+
- โ
Only triggers updates when value actually changes
|
|
114
|
+
- โ
Returns cleanup function from `$subscribe`
|
|
115
|
+
|
|
116
|
+
### `bindDom(root, store)`
|
|
117
|
+
|
|
118
|
+
Binds reactive state to DOM elements with `data-bind` attributes.
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
const cleanup = bindDom(document.body, store);
|
|
122
|
+
|
|
123
|
+
// Later: cleanup all bindings
|
|
124
|
+
cleanup();
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Supports:**
|
|
128
|
+
- โ
Text content: `<span data-bind="count"></span>`
|
|
129
|
+
- โ
Input values: `<input data-bind="name">`
|
|
130
|
+
- โ
Textareas: `<textarea data-bind="bio"></textarea>`
|
|
131
|
+
- โ
Selects: `<select data-bind="theme"></select>`
|
|
132
|
+
- โ
Checkboxes: `<input type="checkbox" data-bind="enabled">`
|
|
133
|
+
- โ
Numbers: `<input type="number" data-bind="age">`
|
|
134
|
+
- โ
Radio buttons: `<input type="radio" data-bind="choice">`
|
|
135
|
+
- โ
Nested paths: `<span data-bind="user.name"></span>`
|
|
136
|
+
|
|
137
|
+
**NEW in v0.3.0:**
|
|
138
|
+
- Returns cleanup function
|
|
139
|
+
- Better error messages with `[Lume.js]` prefix
|
|
140
|
+
- Handles edge cases (empty bindings, invalid paths)
|
|
141
|
+
|
|
142
|
+
### `$subscribe(key, callback)`
|
|
143
|
+
|
|
144
|
+
Manually subscribe to state changes. Returns unsubscribe function.
|
|
145
|
+
|
|
146
|
+
```javascript
|
|
147
|
+
const unsubscribe = store.$subscribe('count', (value) => {
|
|
148
|
+
console.log('Count changed:', value);
|
|
149
|
+
|
|
150
|
+
// Integrate with other libraries
|
|
151
|
+
if (value > 10) {
|
|
152
|
+
showNotification('Count is high!');
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Cleanup
|
|
157
|
+
unsubscribe();
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**NEW in v0.3.0:**
|
|
161
|
+
- Returns unsubscribe function (was missing in v0.2.x)
|
|
162
|
+
- Validates callback is a function
|
|
163
|
+
- Only notifies on actual value changes
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Examples
|
|
168
|
+
|
|
169
|
+
### Basic Counter
|
|
170
|
+
|
|
171
|
+
```javascript
|
|
172
|
+
const store = state({ count: 0 });
|
|
173
|
+
const cleanup = bindDom(document.body, store);
|
|
174
|
+
|
|
175
|
+
document.getElementById('inc').addEventListener('click', () => {
|
|
176
|
+
store.count++;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Cleanup on unmount
|
|
180
|
+
window.addEventListener('beforeunload', () => cleanup());
|
|
38
181
|
```
|
|
39
182
|
|
|
40
|
-
**index.html**
|
|
41
183
|
```html
|
|
42
184
|
<p>Count: <span data-bind="count"></span></p>
|
|
43
|
-
<p>User Name: <span data-bind="user.name"></span></p>
|
|
44
|
-
|
|
45
185
|
<button id="inc">Increment</button>
|
|
46
|
-
<button id="changeName">Change Name</button>
|
|
47
186
|
```
|
|
48
187
|
|
|
49
|
-
|
|
188
|
+
### Form Handling with Validation
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
const form = state({
|
|
192
|
+
email: '',
|
|
193
|
+
age: 25,
|
|
194
|
+
theme: 'light',
|
|
195
|
+
errors: {}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const cleanup = bindDom(document.querySelector('form'), form);
|
|
199
|
+
|
|
200
|
+
// Validate on change
|
|
201
|
+
const unsubEmail = form.$subscribe('email', (value) => {
|
|
202
|
+
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
203
|
+
form.errors = {
|
|
204
|
+
...form.errors,
|
|
205
|
+
email: isValid ? '' : 'Invalid email'
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Cleanup
|
|
210
|
+
window.addEventListener('beforeunload', () => {
|
|
211
|
+
cleanup();
|
|
212
|
+
unsubEmail();
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```html
|
|
217
|
+
<form>
|
|
218
|
+
<input type="email" data-bind="email">
|
|
219
|
+
<span data-bind="errors.email" style="color: red;"></span>
|
|
220
|
+
|
|
221
|
+
<input type="number" data-bind="age">
|
|
222
|
+
|
|
223
|
+
<select data-bind="theme">
|
|
224
|
+
<option value="light">Light</option>
|
|
225
|
+
<option value="dark">Dark</option>
|
|
226
|
+
</select>
|
|
227
|
+
</form>
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Nested State
|
|
231
|
+
|
|
232
|
+
```javascript
|
|
233
|
+
const store = state({
|
|
234
|
+
user: state({
|
|
235
|
+
profile: state({
|
|
236
|
+
name: 'Alice',
|
|
237
|
+
bio: 'Developer'
|
|
238
|
+
}),
|
|
239
|
+
settings: state({
|
|
240
|
+
notifications: true
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const cleanup = bindDom(document.body, store);
|
|
246
|
+
|
|
247
|
+
// Subscribe to nested changes
|
|
248
|
+
const unsub = store.user.profile.$subscribe('name', (name) => {
|
|
249
|
+
console.log('Profile name changed:', name);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Cleanup
|
|
253
|
+
window.addEventListener('beforeunload', () => {
|
|
254
|
+
cleanup();
|
|
255
|
+
unsub();
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
```html
|
|
260
|
+
<input data-bind="user.profile.name">
|
|
261
|
+
<textarea data-bind="user.profile.bio"></textarea>
|
|
262
|
+
<input type="checkbox" data-bind="user.settings.notifications">
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Integration with GSAP
|
|
266
|
+
|
|
267
|
+
```javascript
|
|
268
|
+
import gsap from 'gsap';
|
|
269
|
+
import { state } from 'lume-js';
|
|
270
|
+
|
|
271
|
+
const ui = state({ x: 0, y: 0 });
|
|
272
|
+
|
|
273
|
+
const unsubX = ui.$subscribe('x', (value) => {
|
|
274
|
+
gsap.to('.box', { x: value, duration: 0.5 });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Now ui.x = 100 triggers smooth animation
|
|
278
|
+
|
|
279
|
+
// Cleanup
|
|
280
|
+
window.addEventListener('beforeunload', () => unsubX());
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Cleanup Pattern (Important!)
|
|
284
|
+
|
|
285
|
+
```javascript
|
|
286
|
+
const store = state({ data: [] });
|
|
287
|
+
const cleanup = bindDom(root, store);
|
|
288
|
+
|
|
289
|
+
const unsub1 = store.$subscribe('data', handleData);
|
|
290
|
+
const unsub2 = store.$subscribe('status', handleStatus);
|
|
291
|
+
|
|
292
|
+
// Cleanup when component unmounts
|
|
293
|
+
function destroy() {
|
|
294
|
+
cleanup(); // Remove DOM bindings
|
|
295
|
+
unsub1(); // Remove subscription 1
|
|
296
|
+
unsub2(); // Remove subscription 2
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// For SPA frameworks
|
|
300
|
+
onUnmount(destroy);
|
|
301
|
+
|
|
302
|
+
// For vanilla JS
|
|
303
|
+
window.addEventListener('beforeunload', destroy);
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## Philosophy
|
|
309
|
+
|
|
310
|
+
### Standards-Only
|
|
311
|
+
- Uses only `data-*` attributes (HTML5 standard)
|
|
312
|
+
- Uses only standard JavaScript APIs (Proxy, addEventListener)
|
|
313
|
+
- No custom syntax that breaks validators
|
|
314
|
+
- Works with any tool/library
|
|
315
|
+
|
|
316
|
+
### No Artificial Limitations
|
|
317
|
+
```javascript
|
|
318
|
+
// โ
Use with jQuery
|
|
319
|
+
$('.modal').fadeIn();
|
|
320
|
+
store.modalOpen = true;
|
|
321
|
+
|
|
322
|
+
// โ
Use with GSAP
|
|
323
|
+
gsap.to(el, { x: store.position });
|
|
324
|
+
|
|
325
|
+
// โ
Use with any router
|
|
326
|
+
router.on('/home', () => store.route = 'home');
|
|
327
|
+
|
|
328
|
+
// โ
Mix with vanilla JS
|
|
329
|
+
document.addEventListener('click', () => store.clicks++);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Lume.js doesn't hijack your architecture - it enhances it.**
|
|
333
|
+
|
|
334
|
+
### Progressive Enhancement
|
|
335
|
+
|
|
336
|
+
```html
|
|
337
|
+
<!-- Works without JavaScript (server-rendered) -->
|
|
338
|
+
<form action="/submit" method="POST">
|
|
339
|
+
<input name="email" value="alice@example.com">
|
|
340
|
+
<button type="submit">Save</button>
|
|
341
|
+
</form>
|
|
342
|
+
|
|
343
|
+
<script type="module">
|
|
344
|
+
// Enhanced when JS loads
|
|
345
|
+
import { state, bindDom } from 'lume-js';
|
|
346
|
+
|
|
347
|
+
const form = state({ email: 'alice@example.com' });
|
|
348
|
+
const cleanup = bindDom(document.querySelector('form'), form);
|
|
349
|
+
|
|
350
|
+
// Prevent default, use AJAX
|
|
351
|
+
document.querySelector('form').addEventListener('submit', async (e) => {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
await fetch('/submit', {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
body: JSON.stringify({ email: form.email })
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
window.addEventListener('beforeunload', () => cleanup());
|
|
360
|
+
</script>
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Who Should Use Lume.js?
|
|
366
|
+
|
|
367
|
+
### โ
Perfect For:
|
|
368
|
+
- WordPress/Shopify theme developers
|
|
369
|
+
- Accessibility-focused teams (government, healthcare, education)
|
|
370
|
+
- Legacy codebases that can't do full rewrites
|
|
371
|
+
- Developers who hate learning framework-specific syntax
|
|
372
|
+
- Progressive enhancement advocates
|
|
373
|
+
- Projects requiring HTML validation
|
|
374
|
+
- Adding reactivity to server-rendered apps
|
|
375
|
+
|
|
376
|
+
### โ ๏ธ Consider Alternatives:
|
|
377
|
+
- **Complex SPAs** โ Use React, Vue, or Svelte
|
|
378
|
+
- **Need routing/SSR** โ Use Next.js, Nuxt, or SvelteKit
|
|
379
|
+
- **Prefer terse syntax** โ Use Alpine.js (if custom syntax is okay)
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## What's New in v0.3.0?
|
|
384
|
+
|
|
385
|
+
### Breaking Changes
|
|
386
|
+
- โ
`subscribe` โ `$subscribe` (restored from v0.1.0)
|
|
387
|
+
- โ
`$subscribe` now returns unsubscribe function
|
|
388
|
+
|
|
389
|
+
### New Features
|
|
390
|
+
- โ
TypeScript definitions (`index.d.ts`)
|
|
391
|
+
- โ
`bindDom()` returns cleanup function
|
|
392
|
+
- โ
Better error handling with `[Lume.js]` prefix
|
|
393
|
+
- โ
Input validation (only plain objects)
|
|
394
|
+
- โ
Only triggers on actual value changes
|
|
395
|
+
- โ
Support for checkboxes, radio buttons, number inputs
|
|
396
|
+
- โ
Comprehensive example in `/examples/comprehensive/`
|
|
397
|
+
|
|
398
|
+
### Bug Fixes
|
|
399
|
+
- โ
Fixed memory leaks (no cleanup in v0.2.x)
|
|
400
|
+
- โ
Fixed addon examples (used wrong API)
|
|
401
|
+
- โ
Better path resolution with detailed errors
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## Browser Support
|
|
406
|
+
|
|
407
|
+
- Chrome/Edge 49+
|
|
408
|
+
- Firefox 18+
|
|
409
|
+
- Safari 10+
|
|
410
|
+
- No IE11 (Proxy can't be polyfilled)
|
|
411
|
+
|
|
412
|
+
**Basically: Modern browsers with ES6 Proxy support.**
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Contributing
|
|
417
|
+
|
|
418
|
+
We welcome contributions! Please:
|
|
419
|
+
|
|
420
|
+
1. **Focus on:** Examples, documentation, bug fixes, performance
|
|
421
|
+
2. **Avoid:** Adding core features without discussion (keep it minimal!)
|
|
422
|
+
3. **Check:** Project specification for philosophy
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## License
|
|
427
|
+
|
|
428
|
+
MIT ยฉ Sathvik C
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## Inspiration
|
|
433
|
+
|
|
434
|
+
Lume.js is inspired by:
|
|
435
|
+
- **Knockout.js** - The original `data-bind` approach
|
|
436
|
+
- **Alpine.js** - Minimal, HTML-first philosophy
|
|
437
|
+
- **Go** - Simplicity and explicit design
|
|
438
|
+
- **The Web Platform** - Standards over abstractions
|
|
50
439
|
|
|
51
|
-
|
|
440
|
+
**Built for developers who want reactivity without the framework tax.**
|
package/package.json
CHANGED
|
@@ -1,16 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lume-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required",
|
|
4
5
|
"main": "src/index.js",
|
|
6
|
+
"types": "src/index.d.ts",
|
|
5
7
|
"type": "module",
|
|
6
8
|
"scripts": {
|
|
7
9
|
"dev": "vite",
|
|
8
|
-
"build": "echo 'No build step
|
|
10
|
+
"build": "echo 'No build step needed - zero-runtime library!'",
|
|
11
|
+
"preview": "vite preview"
|
|
9
12
|
},
|
|
10
13
|
"files": [
|
|
11
|
-
"src"
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
12
17
|
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"reactive",
|
|
20
|
+
"state",
|
|
21
|
+
"dom",
|
|
22
|
+
"binding",
|
|
23
|
+
"standards",
|
|
24
|
+
"minimal",
|
|
25
|
+
"no-build",
|
|
26
|
+
"vanilla-js",
|
|
27
|
+
"data-bind",
|
|
28
|
+
"proxy",
|
|
29
|
+
"knockout",
|
|
30
|
+
"html-first",
|
|
31
|
+
"framework-agnostic",
|
|
32
|
+
"minimal-runtime",
|
|
33
|
+
"no-vdom",
|
|
34
|
+
"web-standards",
|
|
35
|
+
"lightweight"
|
|
36
|
+
],
|
|
37
|
+
"author": "Sathvik C",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/sathvikc/lume-js.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/sathvikc/lume-js/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/sathvikc/lume-js#readme",
|
|
13
47
|
"devDependencies": {
|
|
14
48
|
"vite": "^7.1.9"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=20.19.0"
|
|
15
52
|
}
|
|
16
53
|
}
|
package/src/addons/computed.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* computed - creates a derived value based on state
|
|
3
|
+
*
|
|
4
|
+
* NOTE: This is a basic implementation. For production use,
|
|
5
|
+
* consider more robust solutions with automatic dependency tracking.
|
|
6
|
+
*
|
|
3
7
|
* @param {Function} fn - function that computes value from state
|
|
4
|
-
* @returns {Object} - {
|
|
8
|
+
* @returns {Object} - { value, recompute, subscribe }
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const store = state({ count: 0 });
|
|
12
|
+
* const doubled = computed(() => store.count * 2);
|
|
13
|
+
*
|
|
14
|
+
* // Subscribe to changes
|
|
15
|
+
* doubled.subscribe(val => console.log('Doubled:', val));
|
|
16
|
+
*
|
|
17
|
+
* // Manually trigger recomputation after state changes
|
|
18
|
+
* store.$subscribe('count', () => doubled.recompute());
|
|
5
19
|
*/
|
|
6
20
|
export function computed(fn) {
|
|
7
21
|
let value;
|
|
@@ -20,7 +34,7 @@ export function computed(fn) {
|
|
|
20
34
|
};
|
|
21
35
|
|
|
22
36
|
return {
|
|
23
|
-
get
|
|
37
|
+
get value() {
|
|
24
38
|
if (dirty) recalc();
|
|
25
39
|
return value;
|
|
26
40
|
},
|
|
@@ -32,7 +46,8 @@ export function computed(fn) {
|
|
|
32
46
|
subscribers.add(cb);
|
|
33
47
|
// Immediately notify subscriber with current value
|
|
34
48
|
if (!dirty) cb(value);
|
|
49
|
+
else recalc(); // Compute first time
|
|
35
50
|
return () => subscribers.delete(cb); // unsubscribe function
|
|
36
51
|
},
|
|
37
52
|
};
|
|
38
|
-
}
|
|
53
|
+
}
|
package/src/addons/watch.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* @param {Object} store - reactive store created with state()
|
|
4
4
|
* @param {string} key - key in store to watch
|
|
5
5
|
* @param {Function} callback - called with new value
|
|
6
|
+
* @returns {Function} unsubscribe function
|
|
6
7
|
*/
|
|
7
8
|
export function watch(store, key, callback) {
|
|
8
|
-
if (!store
|
|
9
|
+
if (!store.$subscribe) {
|
|
9
10
|
throw new Error("store must be created with state()");
|
|
10
11
|
}
|
|
11
|
-
store
|
|
12
|
-
}
|
|
12
|
+
return store.$subscribe(key, callback);
|
|
13
|
+
}
|
package/src/core/bindDom.js
CHANGED
|
@@ -1,50 +1,128 @@
|
|
|
1
|
+
// src/core/bindDom.js
|
|
1
2
|
/**
|
|
2
|
-
* Lume-JS
|
|
3
|
+
* Lume-JS DOM Binding
|
|
3
4
|
*
|
|
4
5
|
* Binds reactive state to DOM elements using [data-bind].
|
|
5
|
-
* Supports two-way binding for INPUT/TEXTAREA.
|
|
6
|
+
* Supports two-way binding for INPUT/TEXTAREA/SELECT.
|
|
6
7
|
*
|
|
7
8
|
* Usage:
|
|
8
9
|
* import { bindDom } from "lume-js";
|
|
9
|
-
* bindDom(document.body, store);
|
|
10
|
+
* const cleanup = bindDom(document.body, store);
|
|
11
|
+
* // Later: cleanup();
|
|
10
12
|
*
|
|
11
13
|
* HTML:
|
|
12
14
|
* <span data-bind="count"></span>
|
|
13
15
|
* <input data-bind="user.name">
|
|
16
|
+
* <select data-bind="theme"></select>
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import { resolvePath } from "./utils.js";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
|
-
*
|
|
22
|
+
* DOM binding for reactive state
|
|
20
23
|
*
|
|
21
|
-
* @param {HTMLElement} root -
|
|
22
|
-
* @param {object} store -
|
|
24
|
+
* @param {HTMLElement} root - Root element to scan for [data-bind]
|
|
25
|
+
* @param {object} store - Reactive state object
|
|
26
|
+
* @returns {function} Cleanup function to remove all bindings
|
|
23
27
|
*/
|
|
24
28
|
export function bindDom(root, store) {
|
|
29
|
+
if (!(root instanceof HTMLElement)) {
|
|
30
|
+
throw new Error('bindDom() requires a valid HTMLElement as root');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!store || typeof store !== 'object') {
|
|
34
|
+
throw new Error('bindDom() requires a reactive state object');
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
const nodes = root.querySelectorAll("[data-bind]");
|
|
38
|
+
const unsubscribers = [];
|
|
26
39
|
|
|
27
40
|
nodes.forEach(el => {
|
|
28
|
-
const
|
|
41
|
+
const bindPath = el.getAttribute("data-bind");
|
|
42
|
+
|
|
43
|
+
if (!bindPath) {
|
|
44
|
+
console.warn('[Lume.js] Empty data-bind attribute found', el);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pathArr = bindPath.split(".");
|
|
29
49
|
const lastKey = pathArr.pop();
|
|
30
50
|
|
|
31
51
|
let target;
|
|
32
52
|
try {
|
|
33
|
-
target = resolvePath(store, pathArr);
|
|
53
|
+
target = resolvePath(store, pathArr);
|
|
34
54
|
} catch (err) {
|
|
35
|
-
console.warn(`
|
|
55
|
+
console.warn(`[Lume.js] Invalid binding path "${bindPath}":`, err.message);
|
|
36
56
|
return;
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
if (!target || typeof target.$subscribe !== 'function') {
|
|
60
|
+
console.warn(`[Lume.js] Target for "${bindPath}" is not a reactive state object`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Subscribe to changes
|
|
65
|
+
const unsub = target.$subscribe(lastKey, val => {
|
|
66
|
+
updateElement(el, val);
|
|
43
67
|
});
|
|
44
68
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
69
|
+
unsubscribers.push(unsub);
|
|
70
|
+
|
|
71
|
+
// Two-way binding for form inputs
|
|
72
|
+
if (isFormInput(el)) {
|
|
73
|
+
el.addEventListener("input", e => {
|
|
74
|
+
target[lastKey] = getInputValue(e.target);
|
|
75
|
+
});
|
|
48
76
|
}
|
|
49
77
|
});
|
|
78
|
+
|
|
79
|
+
// Return cleanup function
|
|
80
|
+
return () => {
|
|
81
|
+
unsubscribers.forEach(unsub => unsub());
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Update DOM element with new value
|
|
87
|
+
* @private
|
|
88
|
+
*/
|
|
89
|
+
function updateElement(el, val) {
|
|
90
|
+
if (el.tagName === "INPUT") {
|
|
91
|
+
if (el.type === "checkbox") {
|
|
92
|
+
el.checked = Boolean(val);
|
|
93
|
+
} else if (el.type === "radio") {
|
|
94
|
+
el.checked = el.value === String(val);
|
|
95
|
+
} else {
|
|
96
|
+
el.value = val ?? '';
|
|
97
|
+
}
|
|
98
|
+
} else if (el.tagName === "TEXTAREA") {
|
|
99
|
+
el.value = val ?? '';
|
|
100
|
+
} else if (el.tagName === "SELECT") {
|
|
101
|
+
el.value = val ?? '';
|
|
102
|
+
} else {
|
|
103
|
+
el.textContent = val ?? '';
|
|
104
|
+
}
|
|
50
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get value from form input
|
|
109
|
+
* @private
|
|
110
|
+
*/
|
|
111
|
+
function getInputValue(el) {
|
|
112
|
+
if (el.type === "checkbox") {
|
|
113
|
+
return el.checked;
|
|
114
|
+
} else if (el.type === "number" || el.type === "range") {
|
|
115
|
+
return el.valueAsNumber;
|
|
116
|
+
}
|
|
117
|
+
return el.value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if element is a form input
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
function isFormInput(el) {
|
|
125
|
+
return el.tagName === "INPUT" ||
|
|
126
|
+
el.tagName === "TEXTAREA" ||
|
|
127
|
+
el.tagName === "SELECT";
|
|
128
|
+
}
|
package/src/core/state.js
CHANGED
|
@@ -1,32 +1,41 @@
|
|
|
1
|
+
// src/core/state.js
|
|
1
2
|
/**
|
|
2
3
|
* Lume-JS Reactive State Core
|
|
3
4
|
*
|
|
4
|
-
* Provides minimal
|
|
5
|
+
* Provides minimal reactive state with standard JavaScript.
|
|
5
6
|
*
|
|
6
7
|
* Features:
|
|
7
8
|
* - Lightweight and Go-style
|
|
8
9
|
* - Explicit nested states
|
|
9
|
-
* - subscribe for listening to key changes
|
|
10
|
+
* - $subscribe for listening to key changes
|
|
11
|
+
* - Cleanup with unsubscribe
|
|
10
12
|
*
|
|
11
13
|
* Usage:
|
|
12
14
|
* import { state } from "lume-js";
|
|
13
15
|
* const store = state({ count: 0 });
|
|
14
|
-
* store
|
|
16
|
+
* const unsub = store.$subscribe("count", val => console.log(val));
|
|
17
|
+
* unsub(); // cleanup
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
|
-
|
|
18
20
|
/**
|
|
19
21
|
* Creates a reactive state object.
|
|
20
22
|
*
|
|
21
23
|
* @param {Object} obj - Initial state object
|
|
22
|
-
* @returns {Proxy} Reactive proxy with subscribe method
|
|
24
|
+
* @returns {Proxy} Reactive proxy with $subscribe method
|
|
23
25
|
*/
|
|
24
26
|
export function state(obj) {
|
|
27
|
+
// Validate input
|
|
28
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
29
|
+
throw new Error('state() requires a plain object');
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
const listeners = {};
|
|
26
33
|
|
|
27
34
|
// Notify subscribers of a key
|
|
28
35
|
function notify(key, val) {
|
|
29
|
-
if (listeners[key])
|
|
36
|
+
if (listeners[key]) {
|
|
37
|
+
listeners[key].forEach(fn => fn(val));
|
|
38
|
+
}
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
const proxy = new Proxy(obj, {
|
|
@@ -34,8 +43,13 @@ export function state(obj) {
|
|
|
34
43
|
return target[key];
|
|
35
44
|
},
|
|
36
45
|
set(target, key, value) {
|
|
46
|
+
const oldValue = target[key];
|
|
37
47
|
target[key] = value;
|
|
38
|
-
|
|
48
|
+
|
|
49
|
+
// Only notify if value actually changed
|
|
50
|
+
if (oldValue !== value) {
|
|
51
|
+
notify(key, value);
|
|
52
|
+
}
|
|
39
53
|
return true;
|
|
40
54
|
}
|
|
41
55
|
});
|
|
@@ -43,15 +57,30 @@ export function state(obj) {
|
|
|
43
57
|
/**
|
|
44
58
|
* Subscribe to changes for a specific key.
|
|
45
59
|
* Calls the callback immediately with the current value.
|
|
60
|
+
* Returns an unsubscribe function for cleanup.
|
|
46
61
|
*
|
|
47
|
-
* @param {string} key
|
|
48
|
-
* @param {function} fn
|
|
62
|
+
* @param {string} key - Property key to watch
|
|
63
|
+
* @param {function} fn - Callback function
|
|
64
|
+
* @returns {function} Unsubscribe function
|
|
49
65
|
*/
|
|
50
|
-
proxy
|
|
66
|
+
proxy.$subscribe = (key, fn) => {
|
|
67
|
+
if (typeof fn !== 'function') {
|
|
68
|
+
throw new Error('Subscriber must be a function');
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
if (!listeners[key]) listeners[key] = [];
|
|
52
72
|
listeners[key].push(fn);
|
|
53
|
-
|
|
73
|
+
|
|
74
|
+
// Call immediately with current value
|
|
75
|
+
fn(proxy[key]);
|
|
76
|
+
|
|
77
|
+
// Return unsubscribe function
|
|
78
|
+
return () => {
|
|
79
|
+
if (listeners[key]) {
|
|
80
|
+
listeners[key] = listeners[key].filter(subscriber => subscriber !== fn);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
54
83
|
};
|
|
55
84
|
|
|
56
85
|
return proxy;
|
|
57
|
-
}
|
|
86
|
+
}
|
package/src/core/utils.js
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for Lume.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Resolve a nested path in an object.
|
|
3
|
-
* Example:
|
|
7
|
+
* Example: resolvePath(obj, ['user', 'address']) returns obj.user.address
|
|
4
8
|
*
|
|
5
9
|
* @param {object} obj - The root object
|
|
6
10
|
* @param {string[]} pathArr - Array of keys
|
|
7
11
|
* @returns {object} Last object in the path
|
|
12
|
+
* @throws {Error} If path is invalid or doesn't exist
|
|
8
13
|
*/
|
|
9
14
|
export function resolvePath(obj, pathArr) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
+
// If no path, return the object itself
|
|
16
|
+
if (!pathArr || pathArr.length === 0) {
|
|
17
|
+
return obj;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let current = obj;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < pathArr.length; i++) {
|
|
23
|
+
const key = pathArr[i];
|
|
24
|
+
|
|
25
|
+
if (current === null || current === undefined) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Cannot access property "${key}" of ${current} at path: ${pathArr.slice(0, i + 1).join('.')}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!(key in current)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Property "${key}" does not exist at path: ${pathArr.slice(0, i + 1).join('.')}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
current = current[key];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return current;
|
|
41
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lume.js TypeScript Definitions
|
|
3
|
+
*
|
|
4
|
+
* Provides type safety for reactive state management
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Unsubscribe function returned by $subscribe
|
|
9
|
+
*/
|
|
10
|
+
export type Unsubscribe = () => void;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Subscriber callback function
|
|
14
|
+
*/
|
|
15
|
+
export type Subscriber<T> = (value: T) => void;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reactive state object with $subscribe method
|
|
19
|
+
*/
|
|
20
|
+
export type ReactiveState<T extends object> = T & {
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to changes on a specific property key
|
|
23
|
+
* @param key - Property key to watch
|
|
24
|
+
* @param callback - Function called when property changes
|
|
25
|
+
* @returns Unsubscribe function for cleanup
|
|
26
|
+
*/
|
|
27
|
+
$subscribe<K extends keyof T>(
|
|
28
|
+
key: K,
|
|
29
|
+
callback: Subscriber<T[K]>
|
|
30
|
+
): Unsubscribe;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a reactive state object
|
|
35
|
+
*
|
|
36
|
+
* @param obj - Plain object to make reactive
|
|
37
|
+
* @returns Reactive proxy with $subscribe method
|
|
38
|
+
* @throws {Error} If obj is not a plain object
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* const store = state({
|
|
43
|
+
* count: 0,
|
|
44
|
+
* user: state({
|
|
45
|
+
* name: 'Alice'
|
|
46
|
+
* })
|
|
47
|
+
* });
|
|
48
|
+
*
|
|
49
|
+
* store.count++; // Triggers reactivity
|
|
50
|
+
*
|
|
51
|
+
* const unsub = store.$subscribe('count', (val) => {
|
|
52
|
+
* console.log('Count:', val);
|
|
53
|
+
* });
|
|
54
|
+
*
|
|
55
|
+
* // Cleanup
|
|
56
|
+
* unsub();
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function state<T extends object>(obj: T): ReactiveState<T>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Bind reactive state to DOM elements
|
|
63
|
+
*
|
|
64
|
+
* @param root - Root element to scan for [data-bind] attributes
|
|
65
|
+
* @param store - Reactive state object
|
|
66
|
+
* @returns Cleanup function to remove all bindings
|
|
67
|
+
* @throws {Error} If root is not an HTMLElement
|
|
68
|
+
* @throws {Error} If store is not a reactive state object
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* const store = state({ count: 0 });
|
|
73
|
+
* const cleanup = bindDom(document.body, store);
|
|
74
|
+
*
|
|
75
|
+
* // Later: cleanup all bindings
|
|
76
|
+
* cleanup();
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* HTML:
|
|
80
|
+
* ```html
|
|
81
|
+
* <span data-bind="count"></span>
|
|
82
|
+
* <input data-bind="name">
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function bindDom(
|
|
86
|
+
root: HTMLElement,
|
|
87
|
+
store: ReactiveState<any>
|
|
88
|
+
): Unsubscribe;
|