lume-js 0.4.0 → 0.4.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/README.md +181 -4
- package/package.json +12 -1
- package/src/addons/index.d.ts +85 -0
- package/src/core/bindDom.js +82 -39
- package/src/core/state.js +22 -1
- package/src/index.d.ts +29 -2
- package/src/index.js +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required, no framework lock-in.
|
|
6
6
|
|
|
7
7
|
[](LICENSE)
|
|
8
|
-
[](package.json)
|
|
9
9
|
|
|
10
10
|
## Why Lume.js?
|
|
11
11
|
|
|
@@ -29,6 +29,8 @@ Minimal reactive state management using only standard JavaScript and HTML - no c
|
|
|
29
29
|
|
|
30
30
|
**Lume.js is essentially "Modern Knockout.js" - standards-only reactivity for 2025.**
|
|
31
31
|
|
|
32
|
+
📖 **New to the project?** Read [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) to understand our design philosophy and why certain choices were made.
|
|
33
|
+
|
|
32
34
|
---
|
|
33
35
|
|
|
34
36
|
## Installation
|
|
@@ -164,17 +166,29 @@ cleanup(); // Stop the effect
|
|
|
164
166
|
|
|
165
167
|
**How it works:** Effects use `globalThis.__LUME_CURRENT_EFFECT__` to track which state properties are accessed during execution. When any tracked property changes, the effect is queued in that state's pending effects set and runs once in the next microtask.
|
|
166
168
|
|
|
167
|
-
### `bindDom(root, store)`
|
|
169
|
+
### `bindDom(root, store, options?)`
|
|
168
170
|
|
|
169
171
|
Binds reactive state to DOM elements with `data-bind` attributes.
|
|
170
172
|
|
|
173
|
+
**Automatically waits for DOMContentLoaded** if the document is still loading, making it safe to call from anywhere (even in `<head>`).
|
|
174
|
+
|
|
171
175
|
```javascript
|
|
176
|
+
// Default: Auto-waits for DOM (safe anywhere)
|
|
172
177
|
const cleanup = bindDom(document.body, store);
|
|
173
178
|
|
|
179
|
+
// Advanced: Force immediate binding (no auto-wait)
|
|
180
|
+
const cleanup = bindDom(myElement, store, { immediate: true });
|
|
181
|
+
|
|
174
182
|
// Later: cleanup all bindings
|
|
175
183
|
cleanup();
|
|
176
184
|
```
|
|
177
185
|
|
|
186
|
+
**Parameters:**
|
|
187
|
+
- `root` (HTMLElement) - Root element to scan for `[data-bind]` attributes
|
|
188
|
+
- `store` (Object) - Reactive state object
|
|
189
|
+
- `options` (Object, optional)
|
|
190
|
+
- `immediate` (Boolean) - Skip auto-wait, bind immediately. Default: `false`
|
|
191
|
+
|
|
178
192
|
**Supports:**
|
|
179
193
|
- ✅ Text content: `<span data-bind="count"></span>`
|
|
180
194
|
- ✅ Input values: `<input data-bind="name">`
|
|
@@ -185,12 +199,108 @@ cleanup();
|
|
|
185
199
|
- ✅ Radio buttons: `<input type="radio" data-bind="choice">`
|
|
186
200
|
- ✅ Nested paths: `<span data-bind="user.name"></span>`
|
|
187
201
|
|
|
202
|
+
**Multiple Checkboxes Pattern:**
|
|
203
|
+
|
|
204
|
+
For multiple checkboxes, use nested state instead of arrays:
|
|
205
|
+
|
|
206
|
+
```javascript
|
|
207
|
+
// ✅ Recommended: Nested state objects
|
|
208
|
+
const store = state({
|
|
209
|
+
tags: state({
|
|
210
|
+
javascript: true,
|
|
211
|
+
python: false,
|
|
212
|
+
go: true
|
|
213
|
+
})
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```html
|
|
218
|
+
<input type="checkbox" data-bind="tags.javascript"> JavaScript
|
|
219
|
+
<input type="checkbox" data-bind="tags.python"> Python
|
|
220
|
+
<input type="checkbox" data-bind="tags.go"> Go
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Nested state is **more explicit and easier to validate** than array-based bindings. See [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md#why-nested-state-for-multiple-checkboxes-instead-of-arrays) for the full rationale.
|
|
224
|
+
|
|
188
225
|
**Features:**
|
|
226
|
+
- ✅ Auto-waits for DOM if needed (no timing issues!)
|
|
189
227
|
- ✅ Returns cleanup function
|
|
190
228
|
- ✅ Better error messages with `[Lume.js]` prefix
|
|
191
229
|
- ✅ Handles edge cases (empty bindings, invalid paths)
|
|
192
230
|
- ✅ Two-way binding for form inputs
|
|
193
231
|
|
|
232
|
+
### `isReactive(obj)`
|
|
233
|
+
|
|
234
|
+
Checks whether a value is a reactive proxy created by `state()`.
|
|
235
|
+
|
|
236
|
+
```javascript
|
|
237
|
+
import { state, isReactive } from 'lume-js';
|
|
238
|
+
|
|
239
|
+
const original = { count: 1 };
|
|
240
|
+
const store = state(original);
|
|
241
|
+
|
|
242
|
+
isReactive(store); // true
|
|
243
|
+
isReactive(original); // false
|
|
244
|
+
isReactive(null); // false
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**How it works:**
|
|
248
|
+
Lume.js uses an internal `Symbol` checked via the Proxy `get` trap rather than mutating the proxy or storing external WeakSet state. Accessing `obj[REACTIVE_SYMBOL]` returns `true` only for reactive proxies, and the symbol is not enumerable or visible via `Object.getOwnPropertySymbols`.
|
|
249
|
+
|
|
250
|
+
**Characteristics:**
|
|
251
|
+
- ✅ Zero mutation of the proxy
|
|
252
|
+
- ✅ Invisible to enumeration and reflection
|
|
253
|
+
- ✅ Fast: single symbol identity check in the `get` path
|
|
254
|
+
- ✅ Supports nested reactive states naturally
|
|
255
|
+
- ✅ Skips tracking meta `$`-prefixed methods (e.g. `$subscribe`)
|
|
256
|
+
|
|
257
|
+
**When to use:** Utility/debugging, conditional wrapping patterns like:
|
|
258
|
+
```javascript
|
|
259
|
+
function ensureReactive(val) {
|
|
260
|
+
return isReactive(val) ? val : state(val);
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Why Auto-Ready?**
|
|
265
|
+
|
|
266
|
+
Works seamlessly regardless of script placement:
|
|
267
|
+
|
|
268
|
+
```html
|
|
269
|
+
<!-- ✅ Works in <head> -->
|
|
270
|
+
<script type="module">
|
|
271
|
+
import { state, bindDom } from 'lume-js';
|
|
272
|
+
const store = state({ count: 0 });
|
|
273
|
+
bindDom(document.body, store); // Auto-waits for DOM!
|
|
274
|
+
</script>
|
|
275
|
+
|
|
276
|
+
<!-- ✅ Works inline in <body> -->
|
|
277
|
+
<body>
|
|
278
|
+
<span data-bind="count"></span>
|
|
279
|
+
<script type="module">
|
|
280
|
+
// bindDom() waits for DOMContentLoaded automatically
|
|
281
|
+
</script>
|
|
282
|
+
</body>
|
|
283
|
+
|
|
284
|
+
<!-- ✅ Works with defer -->
|
|
285
|
+
<script type="module" defer>
|
|
286
|
+
// Already loaded, executes immediately
|
|
287
|
+
</script>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**When to use `immediate: true`:**
|
|
291
|
+
|
|
292
|
+
Rare scenarios where you're dynamically creating DOM or need precise control:
|
|
293
|
+
|
|
294
|
+
```javascript
|
|
295
|
+
// Dynamic DOM injection
|
|
296
|
+
const container = document.createElement('div');
|
|
297
|
+
container.innerHTML = '<span data-bind="count"></span>';
|
|
298
|
+
document.body.appendChild(container);
|
|
299
|
+
|
|
300
|
+
// Bind immediately (DOM already exists)
|
|
301
|
+
bindDom(container, store, { immediate: true });
|
|
302
|
+
```
|
|
303
|
+
|
|
194
304
|
### `$subscribe(key, callback)`
|
|
195
305
|
|
|
196
306
|
Manually subscribe to state changes. Calls callback immediately with current value, then on every change.
|
|
@@ -277,6 +387,70 @@ unwatch();
|
|
|
277
387
|
|
|
278
388
|
---
|
|
279
389
|
|
|
390
|
+
## Choosing the Right Reactive Pattern
|
|
391
|
+
|
|
392
|
+
Lume.js provides three ways to react to state changes. Here's when to use each:
|
|
393
|
+
|
|
394
|
+
| Pattern | Use When | Pros | Cons |
|
|
395
|
+
|---------|----------|------|------|
|
|
396
|
+
| **`bindDom()`** | Syncing state ↔ DOM | Zero code, declarative HTML | DOM-only, no custom logic |
|
|
397
|
+
| **`$subscribe()`** | Listening to specific keys | Explicit, immediate, simple | Manual dependency tracking |
|
|
398
|
+
| **`effect()`** | Auto-run code on any state access | Automatic dependencies, concise | Microtask delay, can infinite loop |
|
|
399
|
+
| **`computed()`** | Deriving values from state | Cached, automatic recompute | Addon import, slight overhead |
|
|
400
|
+
|
|
401
|
+
**Quick Decision Tree:**
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
Need to update DOM?
|
|
405
|
+
├─ Yes, just sync form/text → Use bindDom()
|
|
406
|
+
└─ No, custom logic needed
|
|
407
|
+
├─ Watch single key? → Use $subscribe()
|
|
408
|
+
├─ Watch multiple keys dynamically? → Use effect()
|
|
409
|
+
└─ Derive a value? → Use computed()
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
**Examples:**
|
|
413
|
+
|
|
414
|
+
```javascript
|
|
415
|
+
// 1. bindDom - Zero code DOM sync
|
|
416
|
+
<span data-bind="count"></span>
|
|
417
|
+
bindDom(document.body, store);
|
|
418
|
+
|
|
419
|
+
// 2. $subscribe - Specific key, immediate execution
|
|
420
|
+
store.$subscribe('count', (val) => {
|
|
421
|
+
if (val > 10) showNotification('High!');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// 3. effect - Multiple keys, automatic tracking
|
|
425
|
+
effect(() => {
|
|
426
|
+
document.title = `${store.user.name}: ${store.count}`;
|
|
427
|
+
// Tracks both user.name and count automatically
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// 4. computed - Derive cached value
|
|
431
|
+
import { computed } from 'lume-js/addons';
|
|
432
|
+
const total = computed(() => store.items.reduce((sum, i) => sum + i.price, 0));
|
|
433
|
+
console.log(total.value);
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**Gotchas:**
|
|
437
|
+
|
|
438
|
+
- ⚠️ **effect()** runs in next microtask (~0.002ms delay). Use `$subscribe()` for immediate execution.
|
|
439
|
+
- ⚠️ **Don't mutate tracked state inside effect** - causes infinite loops:
|
|
440
|
+
```javascript
|
|
441
|
+
// ❌ BAD - Infinite loop
|
|
442
|
+
effect(() => {
|
|
443
|
+
store.count++; // Writes to what it reads!
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ✅ GOOD - Read-only or separate keys
|
|
447
|
+
effect(() => {
|
|
448
|
+
store.displayCount = store.count * 2; // Different keys
|
|
449
|
+
});
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
280
454
|
## Examples
|
|
281
455
|
|
|
282
456
|
### Basic Counter
|
|
@@ -619,7 +793,7 @@ resolve: {
|
|
|
619
793
|
**Current coverage:**
|
|
620
794
|
- 100% statements, functions, and lines
|
|
621
795
|
- 100% branches (including edge-case paths)
|
|
622
|
-
-
|
|
796
|
+
- 67 tests covering core behavior, addons, inputs (text/checkbox/radio/number/range/select/textarea), nested state, reactive identity, and cleanup semantics
|
|
623
797
|
|
|
624
798
|
---
|
|
625
799
|
|
|
@@ -629,7 +803,10 @@ We welcome contributions! Please:
|
|
|
629
803
|
|
|
630
804
|
1. **Focus on:** Examples, documentation, bug fixes, performance
|
|
631
805
|
2. **Avoid:** Adding core features without discussion (keep it minimal!)
|
|
632
|
-
3. **
|
|
806
|
+
3. **Read:** [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) to understand our philosophy and why certain choices were made
|
|
807
|
+
4. **Propose alternatives:** If you think a design decision should be reconsidered, open an issue with your reasoning
|
|
808
|
+
|
|
809
|
+
Before suggesting new features, check if they align with Lume's core principles: standards-only, minimal API, no build step required.
|
|
633
810
|
|
|
634
811
|
---
|
|
635
812
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lume-js",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./src/index.js",
|
|
12
|
+
"types": "./src/index.d.ts"
|
|
13
|
+
},
|
|
14
|
+
"./addons": {
|
|
15
|
+
"import": "./src/addons/index.js",
|
|
16
|
+
"types": "./src/addons/index.d.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
8
19
|
"scripts": {
|
|
9
20
|
"dev": "vite",
|
|
10
21
|
"build": "echo 'No build step needed - zero-runtime library!'",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lume.js Addons TypeScript Definitions
|
|
3
|
+
*
|
|
4
|
+
* Optional utilities for advanced reactive patterns.
|
|
5
|
+
* Import from "lume-js/addons" for tree-shaking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Unsubscribe, Subscriber, ReactiveState } from '../index.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Computed value container returned by computed().
|
|
12
|
+
* T is the computed result type; when an error occurs the value becomes undefined.
|
|
13
|
+
*/
|
|
14
|
+
export interface Computed<T> {
|
|
15
|
+
/** Current computed value (undefined if computation threw). */
|
|
16
|
+
readonly value: T | undefined;
|
|
17
|
+
/** Subscribe to changes; immediate invocation with current value (may be undefined). */
|
|
18
|
+
subscribe(callback: Subscriber<T | undefined>): Unsubscribe;
|
|
19
|
+
/** Dispose computed value and stop tracking dependencies. */
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a computed value that automatically re-evaluates when accessed reactive state keys change.
|
|
25
|
+
* Uses effect() internally for dependency tracking.
|
|
26
|
+
*
|
|
27
|
+
* @param fn - Pure function that derives a value from reactive state.
|
|
28
|
+
* @returns Computed value container with .value, .subscribe(), and .dispose()
|
|
29
|
+
* @throws {Error} If fn is not a function
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* import { state } from 'lume-js';
|
|
34
|
+
* import { computed } from 'lume-js/addons';
|
|
35
|
+
*
|
|
36
|
+
* const store = state({ count: 5 });
|
|
37
|
+
* const doubled = computed(() => store.count * 2);
|
|
38
|
+
*
|
|
39
|
+
* console.log(doubled.value); // 10
|
|
40
|
+
*
|
|
41
|
+
* store.count = 10;
|
|
42
|
+
* // After microtask:
|
|
43
|
+
* console.log(doubled.value); // 20 (auto-updated)
|
|
44
|
+
*
|
|
45
|
+
* // Subscribe to changes
|
|
46
|
+
* const unsub = doubled.subscribe(value => {
|
|
47
|
+
* console.log('Doubled:', value);
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // Cleanup
|
|
51
|
+
* doubled.dispose();
|
|
52
|
+
* unsub();
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function computed<T>(fn: () => T): Computed<T>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Watch a single key on a reactive state object; convenience wrapper around $subscribe.
|
|
59
|
+
*
|
|
60
|
+
* @param store - Reactive state object created with state().
|
|
61
|
+
* @param key - Property key to observe.
|
|
62
|
+
* @param callback - Invoked immediately and on subsequent changes.
|
|
63
|
+
* @returns Unsubscribe function for cleanup
|
|
64
|
+
* @throws {Error} If store is not a reactive state object
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* import { state } from 'lume-js';
|
|
69
|
+
* import { watch } from 'lume-js/addons';
|
|
70
|
+
*
|
|
71
|
+
* const store = state({ count: 0 });
|
|
72
|
+
*
|
|
73
|
+
* const unwatch = watch(store, 'count', (value) => {
|
|
74
|
+
* console.log('Count is now:', value);
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* // Cleanup
|
|
78
|
+
* unwatch();
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function watch<T extends object, K extends keyof T>(
|
|
82
|
+
store: ReactiveState<T>,
|
|
83
|
+
key: K,
|
|
84
|
+
callback: Subscriber<T[K]>
|
|
85
|
+
): Unsubscribe;
|
package/src/core/bindDom.js
CHANGED
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Binds reactive state to DOM elements using [data-bind].
|
|
6
6
|
* Supports two-way binding for INPUT/TEXTAREA/SELECT.
|
|
7
|
+
*
|
|
8
|
+
* Automatically waits for DOMContentLoaded if the document is still loading,
|
|
9
|
+
* ensuring safe binding regardless of when the function is called.
|
|
7
10
|
*
|
|
8
11
|
* Usage:
|
|
9
12
|
* import { bindDom } from "lume-js";
|
|
13
|
+
*
|
|
14
|
+
* // Default: Auto-waits for DOM (safe anywhere)
|
|
10
15
|
* const cleanup = bindDom(document.body, store);
|
|
16
|
+
*
|
|
17
|
+
* // Advanced: Force immediate binding (no auto-wait)
|
|
18
|
+
* const cleanup = bindDom(myElement, store, { immediate: true });
|
|
19
|
+
*
|
|
11
20
|
* // Later: cleanup();
|
|
12
21
|
*
|
|
13
22
|
* HTML:
|
|
@@ -23,9 +32,11 @@ import { resolvePath } from "./utils.js";
|
|
|
23
32
|
*
|
|
24
33
|
* @param {HTMLElement} root - Root element to scan for [data-bind]
|
|
25
34
|
* @param {object} store - Reactive state object
|
|
35
|
+
* @param {object} [options] - Optional configuration
|
|
36
|
+
* @param {boolean} [options.immediate=false] - Skip auto-wait, bind immediately
|
|
26
37
|
* @returns {function} Cleanup function to remove all bindings
|
|
27
38
|
*/
|
|
28
|
-
export function bindDom(root, store) {
|
|
39
|
+
export function bindDom(root, store, options = {}) {
|
|
29
40
|
if (!(root instanceof HTMLElement)) {
|
|
30
41
|
throw new Error('bindDom() requires a valid HTMLElement as root');
|
|
31
42
|
}
|
|
@@ -34,52 +45,79 @@ export function bindDom(root, store) {
|
|
|
34
45
|
throw new Error('bindDom() requires a reactive state object');
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
const
|
|
38
|
-
const cleanups = [];
|
|
48
|
+
const { immediate = false } = options;
|
|
39
49
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
console.warn('[Lume.js] Empty data-bind attribute found', el);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
50
|
+
// Core binding logic extracted to separate function
|
|
51
|
+
const performBinding = () => {
|
|
52
|
+
const nodes = root.querySelectorAll("[data-bind]");
|
|
53
|
+
const cleanups = [];
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
nodes.forEach(el => {
|
|
56
|
+
const bindPath = el.getAttribute("data-bind");
|
|
57
|
+
|
|
58
|
+
if (!bindPath) {
|
|
59
|
+
console.warn('[Lume.js] Empty data-bind attribute found', el);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
target = resolvePath(store, pathArr);
|
|
54
|
-
} catch (err) {
|
|
55
|
-
console.warn(`[Lume.js] Invalid binding path "${bindPath}":`, err.message);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
63
|
+
const pathArr = bindPath.split(".");
|
|
64
|
+
const lastKey = pathArr.pop();
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
let target;
|
|
67
|
+
try {
|
|
68
|
+
target = resolvePath(store, pathArr);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn(`[Lume.js] Invalid binding path "${bindPath}":`, err.message);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!target || typeof target.$subscribe !== 'function') {
|
|
75
|
+
console.warn(`[Lume.js] Target for "${bindPath}" is not a reactive state object`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
// Subscribe to changes - receives already-batched notifications
|
|
80
|
+
const unsubscribe = target.$subscribe(lastKey, val => {
|
|
81
|
+
updateElement(el, val);
|
|
82
|
+
});
|
|
83
|
+
cleanups.push(unsubscribe);
|
|
84
|
+
|
|
85
|
+
// Two-way binding for form inputs
|
|
86
|
+
if (isFormInput(el)) {
|
|
87
|
+
const handler = e => {
|
|
88
|
+
target[lastKey] = getInputValue(e.target);
|
|
89
|
+
};
|
|
90
|
+
el.addEventListener("input", handler);
|
|
91
|
+
cleanups.push(() => el.removeEventListener("input", handler));
|
|
92
|
+
}
|
|
67
93
|
});
|
|
68
|
-
cleanups.push(unsubscribe);
|
|
69
|
-
|
|
70
|
-
// Two-way binding for form inputs
|
|
71
|
-
if (isFormInput(el)) {
|
|
72
|
-
const handler = e => {
|
|
73
|
-
target[lastKey] = getInputValue(e.target);
|
|
74
|
-
};
|
|
75
|
-
el.addEventListener("input", handler);
|
|
76
|
-
cleanups.push(() => el.removeEventListener("input", handler));
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
94
|
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
return () => {
|
|
96
|
+
cleanups.forEach(cleanup => cleanup());
|
|
97
|
+
};
|
|
82
98
|
};
|
|
99
|
+
|
|
100
|
+
// Auto-wait for DOM if needed (unless immediate flag is set)
|
|
101
|
+
if (!immediate && document.readyState === 'loading') {
|
|
102
|
+
let cleanup = null;
|
|
103
|
+
const onReady = () => {
|
|
104
|
+
cleanup = performBinding();
|
|
105
|
+
};
|
|
106
|
+
document.addEventListener('DOMContentLoaded', onReady, { once: true });
|
|
107
|
+
|
|
108
|
+
// Return cleanup function that handles both cases
|
|
109
|
+
return () => {
|
|
110
|
+
if (cleanup) {
|
|
111
|
+
cleanup();
|
|
112
|
+
} else {
|
|
113
|
+
// If cleanup hasn't been created yet, remove the event listener
|
|
114
|
+
document.removeEventListener('DOMContentLoaded', onReady);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Immediate binding (DOM already ready or immediate flag set)
|
|
120
|
+
return performBinding();
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
/**
|
|
@@ -89,8 +127,13 @@ export function bindDom(root, store) {
|
|
|
89
127
|
function updateElement(el, val) {
|
|
90
128
|
if (el.tagName === "INPUT") {
|
|
91
129
|
if (el.type === "checkbox") {
|
|
130
|
+
// Single checkbox: bind to boolean value
|
|
131
|
+
// For multiple checkboxes, use nested state objects:
|
|
132
|
+
// <input data-bind="tags.javascript"> → state({ tags: state({ javascript: true }) })
|
|
92
133
|
el.checked = Boolean(val);
|
|
93
134
|
} else if (el.type === "radio") {
|
|
135
|
+
// Radio: checked when el.value matches state value
|
|
136
|
+
// String() handles null/undefined gracefully (no radio selected)
|
|
94
137
|
el.checked = el.value === String(val);
|
|
95
138
|
} else {
|
|
96
139
|
el.value = val ?? '';
|
package/src/core/state.js
CHANGED
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
* @param {Object} obj - Initial state object
|
|
30
30
|
* @returns {Proxy} Reactive proxy with $subscribe method
|
|
31
31
|
*/
|
|
32
|
+
// Internal symbol used to mark reactive proxies (non-enumerable via Proxy trap)
|
|
33
|
+
const REACTIVE_MARKER = Symbol('__LUME_REACTIVE__');
|
|
34
|
+
|
|
32
35
|
export function state(obj) {
|
|
33
36
|
// Validate input
|
|
34
37
|
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
@@ -59,9 +62,11 @@ export function state(obj) {
|
|
|
59
62
|
flushScheduled = false;
|
|
60
63
|
|
|
61
64
|
// Notify all subscribers of changed keys
|
|
65
|
+
// Snapshot listeners array to handle unsubscribes during iteration
|
|
62
66
|
for (const [key, value] of pendingNotifications) {
|
|
63
67
|
if (listeners[key]) {
|
|
64
|
-
listeners[key]
|
|
68
|
+
const subscribersSnapshot = Array.from(listeners[key]);
|
|
69
|
+
subscribersSnapshot.forEach(fn => fn(value));
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
|
|
@@ -76,6 +81,12 @@ export function state(obj) {
|
|
|
76
81
|
|
|
77
82
|
const proxy = new Proxy(obj, {
|
|
78
83
|
get(target, key) {
|
|
84
|
+
// Reactive marker check (avoid tracking for internal symbol)
|
|
85
|
+
if (key === REACTIVE_MARKER) return true;
|
|
86
|
+
// Skip effect tracking for internal meta methods (e.g. $subscribe)
|
|
87
|
+
if (typeof key === 'string' && key.startsWith('$')) {
|
|
88
|
+
return target[key];
|
|
89
|
+
}
|
|
79
90
|
// Support effect tracking
|
|
80
91
|
// Check if we're inside an effect context
|
|
81
92
|
if (typeof globalThis.__LUME_CURRENT_EFFECT__ !== 'undefined') {
|
|
@@ -156,4 +167,14 @@ export function state(obj) {
|
|
|
156
167
|
};
|
|
157
168
|
|
|
158
169
|
return proxy;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Determine if an object is a Lume reactive proxy.
|
|
174
|
+
* Defensive: ensures object and marker presence.
|
|
175
|
+
* @param {any} obj
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
export function isReactive(obj) {
|
|
179
|
+
return !!(obj && typeof obj === 'object' && obj[REACTIVE_MARKER]);
|
|
159
180
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -58,20 +58,39 @@ export type ReactiveState<T extends object> = T & {
|
|
|
58
58
|
*/
|
|
59
59
|
export function state<T extends object>(obj: T): ReactiveState<T>;
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Options for bindDom function
|
|
63
|
+
*/
|
|
64
|
+
export interface BindDomOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Skip auto-wait for DOM, bind immediately
|
|
67
|
+
* @default false
|
|
68
|
+
*/
|
|
69
|
+
immediate?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
/**
|
|
62
73
|
* Bind reactive state to DOM elements
|
|
63
74
|
*
|
|
75
|
+
* Automatically waits for DOMContentLoaded if the document is still loading,
|
|
76
|
+
* ensuring safe binding regardless of when the function is called.
|
|
77
|
+
*
|
|
64
78
|
* @param root - Root element to scan for [data-bind] attributes
|
|
65
79
|
* @param store - Reactive state object
|
|
80
|
+
* @param options - Optional configuration
|
|
66
81
|
* @returns Cleanup function to remove all bindings
|
|
67
82
|
* @throws {Error} If root is not an HTMLElement
|
|
68
83
|
* @throws {Error} If store is not a reactive state object
|
|
69
84
|
*
|
|
70
85
|
* @example
|
|
71
86
|
* ```typescript
|
|
87
|
+
* // Default: Auto-waits for DOM (safe anywhere)
|
|
72
88
|
* const store = state({ count: 0 });
|
|
73
89
|
* const cleanup = bindDom(document.body, store);
|
|
74
90
|
*
|
|
91
|
+
* // Advanced: Force immediate binding (no auto-wait)
|
|
92
|
+
* const cleanup = bindDom(myElement, store, { immediate: true });
|
|
93
|
+
*
|
|
75
94
|
* // Later: cleanup all bindings
|
|
76
95
|
* cleanup();
|
|
77
96
|
* ```
|
|
@@ -84,7 +103,8 @@ export function state<T extends object>(obj: T): ReactiveState<T>;
|
|
|
84
103
|
*/
|
|
85
104
|
export function bindDom(
|
|
86
105
|
root: HTMLElement,
|
|
87
|
-
store: ReactiveState<any
|
|
106
|
+
store: ReactiveState<any>,
|
|
107
|
+
options?: BindDomOptions
|
|
88
108
|
): Unsubscribe;
|
|
89
109
|
|
|
90
110
|
/**
|
|
@@ -112,4 +132,11 @@ export function bindDom(
|
|
|
112
132
|
* cleanup(); // Stop the effect
|
|
113
133
|
* ```
|
|
114
134
|
*/
|
|
115
|
-
export function effect(fn: () => void): Unsubscribe;
|
|
135
|
+
export function effect(fn: () => void): Unsubscribe;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if a value is a Lume reactive proxy produced by state().
|
|
139
|
+
* Returns true only for objects created by state().
|
|
140
|
+
* @param obj - Value to check
|
|
141
|
+
*/
|
|
142
|
+
export function isReactive(obj: any): boolean;
|
package/src/index.js
CHANGED