lume-js 0.4.0 → 0.5.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 +208 -4
- package/package.json +12 -1
- package/src/addons/index.d.ts +258 -0
- package/src/addons/index.js +2 -1
- package/src/addons/repeat.js +342 -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,17 +793,47 @@ resolve: {
|
|
|
619
793
|
**Current coverage:**
|
|
620
794
|
- 100% statements, functions, and lines
|
|
621
795
|
- 100% branches (including edge-case paths)
|
|
622
|
-
-
|
|
796
|
+
- 114 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
|
|
|
800
|
+
### `repeat(container, store, key, options)`
|
|
801
|
+
|
|
802
|
+
**@experimental** - API may change in future versions.
|
|
803
|
+
|
|
804
|
+
Efficiently renders lists with element reuse and automatic subscription.
|
|
805
|
+
|
|
806
|
+
```javascript
|
|
807
|
+
import { repeat } from 'lume-js/addons/repeat.js';
|
|
808
|
+
|
|
809
|
+
// ⚠️ IMPORTANT: Arrays must be updated immutably!
|
|
810
|
+
// store.items.push(newItem); // ❌ Won't trigger update
|
|
811
|
+
// store.items = [...store.items, newItem]; // ✅ Triggers update
|
|
812
|
+
|
|
813
|
+
repeat('#list', store, 'items', {
|
|
814
|
+
key: item => item.id,
|
|
815
|
+
render: (item, el) => {
|
|
816
|
+
el.textContent = item.name;
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
**Features:**
|
|
822
|
+
- ✅ **Element Reuse** - Reuses DOM nodes by key (no full re-renders)
|
|
823
|
+
- ✅ **Focus Preservation** - Maintains active element and selection during updates
|
|
824
|
+
- ✅ **Scroll Preservation** - Maintains scroll position during updates
|
|
825
|
+
- ✅ **Automatic Subscription** - Subscribes to the array key automatically
|
|
826
|
+
|
|
626
827
|
## Contributing
|
|
627
828
|
|
|
628
829
|
We welcome contributions! Please:
|
|
629
830
|
|
|
630
831
|
1. **Focus on:** Examples, documentation, bug fixes, performance
|
|
631
832
|
2. **Avoid:** Adding core features without discussion (keep it minimal!)
|
|
632
|
-
3. **
|
|
833
|
+
3. **Read:** [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) to understand our philosophy and why certain choices were made
|
|
834
|
+
4. **Propose alternatives:** If you think a design decision should be reconsidered, open an issue with your reasoning
|
|
835
|
+
|
|
836
|
+
Before suggesting new features, check if they align with Lume's core principles: standards-only, minimal API, no build step required.
|
|
633
837
|
|
|
634
838
|
---
|
|
635
839
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lume-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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,258 @@
|
|
|
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;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Context passed to preservation functions
|
|
89
|
+
*/
|
|
90
|
+
export interface PreservationContext {
|
|
91
|
+
/** Whether this update is a reorder (vs add/remove) */
|
|
92
|
+
isReorder?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Focus preservation function signature
|
|
97
|
+
*
|
|
98
|
+
* @param container - The list container element
|
|
99
|
+
* @returns Restore function to call after DOM updates, or null if nothing to restore
|
|
100
|
+
*/
|
|
101
|
+
export type FocusPreservation = (container: HTMLElement) => (() => void) | null;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Scroll preservation function signature
|
|
105
|
+
*
|
|
106
|
+
* @param container - The list container element
|
|
107
|
+
* @param context - Additional context about the update
|
|
108
|
+
* @returns Restore function to call after DOM updates
|
|
109
|
+
*/
|
|
110
|
+
export type ScrollPreservation = (container: HTMLElement, context?: PreservationContext) => () => void;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Options for the repeat() function
|
|
114
|
+
*/
|
|
115
|
+
export interface RepeatOptions<T> {
|
|
116
|
+
/** Function to extract unique key from item */
|
|
117
|
+
key: (item: T) => string | number;
|
|
118
|
+
|
|
119
|
+
/** Function to render/update an item's element */
|
|
120
|
+
render: (item: T, element: HTMLElement, index: number) => void;
|
|
121
|
+
|
|
122
|
+
/** Element tag name or factory function (default: 'div') */
|
|
123
|
+
element?: string | (() => HTMLElement);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Focus preservation strategy (default: defaultFocusPreservation)
|
|
127
|
+
* Set to null to disable focus preservation
|
|
128
|
+
*/
|
|
129
|
+
preserveFocus?: FocusPreservation | null;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Scroll preservation strategy (default: defaultScrollPreservation)
|
|
133
|
+
* Set to null to disable scroll preservation
|
|
134
|
+
*/
|
|
135
|
+
preserveScroll?: ScrollPreservation | null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Default focus preservation strategy
|
|
140
|
+
* Saves activeElement and selection state before DOM updates
|
|
141
|
+
*
|
|
142
|
+
* @param container - The list container element
|
|
143
|
+
* @returns Restore function or null
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* import { defaultFocusPreservation } from 'lume-js/addons';
|
|
148
|
+
*
|
|
149
|
+
* // Use in custom preservation wrapper
|
|
150
|
+
* const myPreservation = (container) => {
|
|
151
|
+
* console.log('Saving focus...');
|
|
152
|
+
* const restore = defaultFocusPreservation(container);
|
|
153
|
+
* return restore ? () => {
|
|
154
|
+
* restore();
|
|
155
|
+
* console.log('Focus restored!');
|
|
156
|
+
* } : null;
|
|
157
|
+
* };
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
export function defaultFocusPreservation(container: HTMLElement): (() => void) | null;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Default scroll preservation strategy
|
|
164
|
+
* Uses anchor-based preservation for add/remove, pixel position for reorder
|
|
165
|
+
*
|
|
166
|
+
* @param container - The list container element
|
|
167
|
+
* @param context - Additional context about the update
|
|
168
|
+
* @returns Restore function
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```typescript
|
|
172
|
+
* import { defaultScrollPreservation } from 'lume-js/addons';
|
|
173
|
+
*
|
|
174
|
+
* // Wrap default behavior
|
|
175
|
+
* const myScrollPreservation = (container, context) => {
|
|
176
|
+
* const restore = defaultScrollPreservation(container, context);
|
|
177
|
+
* return () => {
|
|
178
|
+
* restore();
|
|
179
|
+
* console.log('Scroll position restored');
|
|
180
|
+
* };
|
|
181
|
+
* };
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export function defaultScrollPreservation(container: HTMLElement, context?: PreservationContext): () => void;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Efficiently render a list with element reuse by key.
|
|
188
|
+
*
|
|
189
|
+
* Features:
|
|
190
|
+
* - Element reuse (same DOM nodes, not recreated)
|
|
191
|
+
* - Minimal DOM operations (only updates what changed)
|
|
192
|
+
* - Optional focus preservation (maintains activeElement and selection)
|
|
193
|
+
* - Optional scroll preservation (intelligent positioning)
|
|
194
|
+
* - Fully customizable preservation strategies
|
|
195
|
+
*
|
|
196
|
+
* @param container - Container element or CSS selector
|
|
197
|
+
* @param store - Reactive state object
|
|
198
|
+
* @param arrayKey - Key in store containing the array
|
|
199
|
+
* @param options - Configuration options
|
|
200
|
+
* @returns Cleanup function
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* import { state } from 'lume-js';
|
|
205
|
+
* import { repeat } from 'lume-js/addons';
|
|
206
|
+
*
|
|
207
|
+
* const store = state({
|
|
208
|
+
* todos: [
|
|
209
|
+
* { id: 1, text: 'Learn Lume.js' },
|
|
210
|
+
* { id: 2, text: 'Build an app' }
|
|
211
|
+
* ]
|
|
212
|
+
* });
|
|
213
|
+
*
|
|
214
|
+
* // Basic usage (preservation enabled by default)
|
|
215
|
+
* const cleanup = repeat('#todo-list', store, 'todos', {
|
|
216
|
+
* key: todo => todo.id,
|
|
217
|
+
* render: (todo, el) => {
|
|
218
|
+
* if (!el.dataset.init) {
|
|
219
|
+
* el.innerHTML = `<input value="${todo.text}">`;
|
|
220
|
+
* el.dataset.init = 'true';
|
|
221
|
+
* }
|
|
222
|
+
* }
|
|
223
|
+
* });
|
|
224
|
+
*
|
|
225
|
+
* // Disable preservation (bare-bones)
|
|
226
|
+
* repeat('#list', store, 'todos', {
|
|
227
|
+
* key: todo => todo.id,
|
|
228
|
+
* render: (todo, el) => { el.textContent = todo.text; },
|
|
229
|
+
* preserveFocus: null,
|
|
230
|
+
* preserveScroll: null
|
|
231
|
+
* });
|
|
232
|
+
*
|
|
233
|
+
* // Custom preservation
|
|
234
|
+
* import { defaultFocusPreservation } from 'lume-js/addons';
|
|
235
|
+
*
|
|
236
|
+
* repeat('#list', store, 'todos', {
|
|
237
|
+
* key: todo => todo.id,
|
|
238
|
+
* render: (todo, el) => { ... },
|
|
239
|
+
* preserveFocus: (container) => {
|
|
240
|
+
* // Custom logic
|
|
241
|
+
* const restore = defaultFocusPreservation(container);
|
|
242
|
+
* return () => {
|
|
243
|
+
* restore?.();
|
|
244
|
+
* console.log('Focus restored');
|
|
245
|
+
* };
|
|
246
|
+
* }
|
|
247
|
+
* });
|
|
248
|
+
*
|
|
249
|
+
* // Cleanup
|
|
250
|
+
* cleanup();
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
export function repeat<T>(
|
|
254
|
+
container: string | HTMLElement,
|
|
255
|
+
store: ReactiveState<any>,
|
|
256
|
+
arrayKey: string,
|
|
257
|
+
options: RepeatOptions<T>
|
|
258
|
+
): Unsubscribe;
|
package/src/addons/index.js
CHANGED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lume-JS List Rendering (Addon)
|
|
3
|
+
* @experimental
|
|
4
|
+
*
|
|
5
|
+
* Renders lists with automatic subscription and element reuse by key.
|
|
6
|
+
*
|
|
7
|
+
* Core guarantees:
|
|
8
|
+
* ✅ Element reuse by key (same DOM nodes, not recreated)
|
|
9
|
+
* ✅ Minimal DOM operations (only updates what changed)
|
|
10
|
+
* ✅ Memory efficiency (cleanup on remove)
|
|
11
|
+
*
|
|
12
|
+
* Default behavior (can be disabled/customized):
|
|
13
|
+
* ✅ Focus preservation (maintains activeElement and selection)
|
|
14
|
+
* ✅ Scroll preservation (intelligent positioning for add/remove/reorder)
|
|
15
|
+
*
|
|
16
|
+
* Philosophy: No artificial limitations
|
|
17
|
+
* - All preservation logic is overridable via options
|
|
18
|
+
* - Set to null/false to disable, or provide custom functions
|
|
19
|
+
* - Export utilities so you can wrap/extend them
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* import { repeat } from "lume-js/addons/repeat.js";
|
|
23
|
+
*
|
|
24
|
+
* // ⚠️ IMPORTANT: Arrays must be updated immutably!
|
|
25
|
+
* // store.items.push(x) // ❌ Won't trigger update
|
|
26
|
+
* // store.items = [...items] // ✅ Triggers update
|
|
27
|
+
*
|
|
28
|
+
* // Basic usage (focus & scroll preservation enabled by default)
|
|
29
|
+
* repeat('#list', store, 'todos', {
|
|
30
|
+
* key: todo => todo.id,
|
|
31
|
+
* render: (todo, el) => {
|
|
32
|
+
* if (!el.dataset.init) {
|
|
33
|
+
* el.innerHTML = `<input value="${todo.text}">`;
|
|
34
|
+
* el.dataset.init = 'true';
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* // Disable all preservation (bare-bones repeat)
|
|
40
|
+
* repeat('#list', store, 'items', {
|
|
41
|
+
* key: item => item.id,
|
|
42
|
+
* render: (item, el) => { el.textContent = item.name; },
|
|
43
|
+
* preserveFocus: null,
|
|
44
|
+
* preserveScroll: null
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* // Custom focus preservation
|
|
48
|
+
* repeat('#list', store, 'items', {
|
|
49
|
+
* key: item => item.id,
|
|
50
|
+
* render: (item, el) => { ... },
|
|
51
|
+
* preserveFocus: (container) => {
|
|
52
|
+
* // Your custom logic
|
|
53
|
+
* const state = { focused: document.activeElement };
|
|
54
|
+
* return () => state.focused?.focus();
|
|
55
|
+
* }
|
|
56
|
+
* });
|
|
57
|
+
*
|
|
58
|
+
* // Mix built-in and custom
|
|
59
|
+
* import { defaultFocusPreservation, defaultScrollPreservation } from "lume-js/addons/repeat.js";
|
|
60
|
+
*
|
|
61
|
+
* repeat('#list', store, 'items', {
|
|
62
|
+
* key: item => item.id,
|
|
63
|
+
* render: (item, el) => { ... },
|
|
64
|
+
* preserveFocus: defaultFocusPreservation, // use built-in
|
|
65
|
+
* preserveScroll: (container, context) => {
|
|
66
|
+
* // wrap/extend built-in
|
|
67
|
+
* const restore = defaultScrollPreservation(container, context);
|
|
68
|
+
* return () => {
|
|
69
|
+
* restore();
|
|
70
|
+
* console.log('Scroll restored!');
|
|
71
|
+
* };
|
|
72
|
+
* }
|
|
73
|
+
* });
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// PRESERVATION UTILITIES (Exported for customization)
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Default focus preservation strategy
|
|
82
|
+
* Saves activeElement and selection state before DOM updates
|
|
83
|
+
*
|
|
84
|
+
* @param {HTMLElement} container - The list container
|
|
85
|
+
* @returns {Function|null} Restore function, or null if nothing to restore
|
|
86
|
+
*/
|
|
87
|
+
export function defaultFocusPreservation(container) {
|
|
88
|
+
const activeEl = document.activeElement;
|
|
89
|
+
const shouldRestore = container.contains(activeEl);
|
|
90
|
+
|
|
91
|
+
if (!shouldRestore) return null;
|
|
92
|
+
|
|
93
|
+
let selectionStart = null;
|
|
94
|
+
let selectionEnd = null;
|
|
95
|
+
|
|
96
|
+
if (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA') {
|
|
97
|
+
selectionStart = activeEl.selectionStart;
|
|
98
|
+
selectionEnd = activeEl.selectionEnd;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
if (document.body.contains(activeEl)) {
|
|
103
|
+
activeEl.focus();
|
|
104
|
+
if (selectionStart !== null && selectionEnd !== null) {
|
|
105
|
+
activeEl.setSelectionRange(selectionStart, selectionEnd);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Default scroll preservation strategy
|
|
113
|
+
* Uses anchor-based preservation for add/remove, pixel position for reorder
|
|
114
|
+
*
|
|
115
|
+
* @param {HTMLElement} container - The list container
|
|
116
|
+
* @param {Object} context - Additional context
|
|
117
|
+
* @param {boolean} context.isReorder - Whether this is a reorder operation
|
|
118
|
+
* @returns {Function} Restore function
|
|
119
|
+
*/
|
|
120
|
+
export function defaultScrollPreservation(container, context = {}) {
|
|
121
|
+
const { isReorder = false } = context;
|
|
122
|
+
const scrollTop = container.scrollTop;
|
|
123
|
+
|
|
124
|
+
// Early return if no scroll
|
|
125
|
+
if (scrollTop === 0) {
|
|
126
|
+
return () => { container.scrollTop = 0; };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let anchorElement = null;
|
|
130
|
+
let anchorOffset = 0;
|
|
131
|
+
|
|
132
|
+
// Only use anchor-based preservation for add/remove, not reorder
|
|
133
|
+
if (!isReorder) {
|
|
134
|
+
const containerRect = container.getBoundingClientRect();
|
|
135
|
+
// Avoid Array.from - iterate children directly
|
|
136
|
+
for (let child = container.firstElementChild; child; child = child.nextElementSibling) {
|
|
137
|
+
const rect = child.getBoundingClientRect();
|
|
138
|
+
|
|
139
|
+
if (rect.bottom > containerRect.top) {
|
|
140
|
+
anchorElement = child;
|
|
141
|
+
anchorOffset = rect.top - containerRect.top;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
if (anchorElement && document.body.contains(anchorElement)) {
|
|
149
|
+
const newRect = anchorElement.getBoundingClientRect();
|
|
150
|
+
const containerRect = container.getBoundingClientRect();
|
|
151
|
+
const currentOffset = newRect.top - containerRect.top;
|
|
152
|
+
const scrollAdjustment = currentOffset - anchorOffset;
|
|
153
|
+
|
|
154
|
+
container.scrollTop = container.scrollTop + scrollAdjustment;
|
|
155
|
+
} else {
|
|
156
|
+
container.scrollTop = scrollTop;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// MAIN REPEAT FUNCTION
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Efficiently render a list with element reuse
|
|
167
|
+
*
|
|
168
|
+
* @param {string|HTMLElement} container - Container element or selector
|
|
169
|
+
* @param {Object} store - Reactive state object
|
|
170
|
+
* @param {string} arrayKey - Key in store containing the array
|
|
171
|
+
* @param {Object} options - Configuration
|
|
172
|
+
* @param {Function} options.key - Function to extract unique key: (item) => key
|
|
173
|
+
* @param {Function} options.render - Function to render item: (item, element, index) => void
|
|
174
|
+
* @param {string|Function} [options.element='div'] - Element tag name or factory function
|
|
175
|
+
* @param {Function|null} [options.preserveFocus=defaultFocusPreservation] - Focus preservation strategy (null to disable)
|
|
176
|
+
* @param {Function|null} [options.preserveScroll=defaultScrollPreservation] - Scroll preservation strategy (null to disable)
|
|
177
|
+
* @returns {Function} Cleanup function
|
|
178
|
+
*/
|
|
179
|
+
export function repeat(container, store, arrayKey, options) {
|
|
180
|
+
const {
|
|
181
|
+
key,
|
|
182
|
+
render,
|
|
183
|
+
element = 'div',
|
|
184
|
+
preserveFocus = defaultFocusPreservation,
|
|
185
|
+
preserveScroll = defaultScrollPreservation
|
|
186
|
+
} = options;
|
|
187
|
+
|
|
188
|
+
// Resolve container
|
|
189
|
+
const containerEl =
|
|
190
|
+
typeof container === 'string'
|
|
191
|
+
? document.querySelector(container)
|
|
192
|
+
: container;
|
|
193
|
+
|
|
194
|
+
if (!containerEl) {
|
|
195
|
+
console.warn(`[Lume.js] repeat(): container "${container}" not found`);
|
|
196
|
+
return () => { };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (typeof key !== 'function') {
|
|
200
|
+
throw new Error('[Lume.js] repeat(): options.key must be a function');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (typeof render !== 'function') {
|
|
204
|
+
throw new Error('[Lume.js] repeat(): options.render must be a function');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// key -> HTMLElement
|
|
208
|
+
const elementsByKey = new Map();
|
|
209
|
+
const seenKeys = new Set();
|
|
210
|
+
|
|
211
|
+
function createElement() {
|
|
212
|
+
return typeof element === 'function'
|
|
213
|
+
? element()
|
|
214
|
+
: document.createElement(element);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function updateList() {
|
|
218
|
+
const items = store[arrayKey];
|
|
219
|
+
|
|
220
|
+
if (!Array.isArray(items)) {
|
|
221
|
+
console.warn(`[Lume.js] repeat(): store.${arrayKey} is not an array`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Skip preservation if container is not in document (performance optimization)
|
|
226
|
+
const shouldPreserve = document.body.contains(containerEl);
|
|
227
|
+
|
|
228
|
+
// Only compute isReorder if scroll preservation needs it
|
|
229
|
+
let isReorder = false;
|
|
230
|
+
if (shouldPreserve && preserveScroll) {
|
|
231
|
+
const previousKeys = new Set(elementsByKey.keys());
|
|
232
|
+
const currentKeys = new Set(items.map(item => key(item)));
|
|
233
|
+
isReorder = previousKeys.size === currentKeys.size &&
|
|
234
|
+
[...previousKeys].every(k => currentKeys.has(k));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Save state before DOM manipulation
|
|
238
|
+
const restoreFocus = shouldPreserve && preserveFocus ? preserveFocus(containerEl) : null;
|
|
239
|
+
const restoreScroll = shouldPreserve && preserveScroll ? preserveScroll(containerEl, { isReorder }) : null;
|
|
240
|
+
|
|
241
|
+
seenKeys.clear();
|
|
242
|
+
const nextKeys = new Set();
|
|
243
|
+
const nextEls = [];
|
|
244
|
+
|
|
245
|
+
// Build ordered list of DOM nodes (created or reused)
|
|
246
|
+
for (let i = 0; i < items.length; i++) {
|
|
247
|
+
const item = items[i];
|
|
248
|
+
const k = key(item);
|
|
249
|
+
|
|
250
|
+
if (seenKeys.has(k)) {
|
|
251
|
+
console.warn(`[Lume.js] repeat(): duplicate key "${k}"`);
|
|
252
|
+
}
|
|
253
|
+
seenKeys.add(k);
|
|
254
|
+
nextKeys.add(k);
|
|
255
|
+
|
|
256
|
+
let el = elementsByKey.get(k);
|
|
257
|
+
const isNew = !el;
|
|
258
|
+
|
|
259
|
+
if (isNew) {
|
|
260
|
+
el = createElement();
|
|
261
|
+
elementsByKey.set(k, el);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
if (isNew) {
|
|
266
|
+
el.__lume_new = true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
render(item, el, i);
|
|
270
|
+
|
|
271
|
+
} catch (err) {
|
|
272
|
+
console.error(`[Lume.js] repeat(): error rendering key "${k}"`, err);
|
|
273
|
+
} finally {
|
|
274
|
+
delete el.__lume_new;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
nextEls.push(el);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Reconcile actual DOM ordering
|
|
281
|
+
let ptr = containerEl.firstChild;
|
|
282
|
+
|
|
283
|
+
for (let i = 0; i < nextEls.length; i++) {
|
|
284
|
+
const desired = nextEls[i];
|
|
285
|
+
|
|
286
|
+
if (ptr === desired) {
|
|
287
|
+
ptr = ptr.nextSibling;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
containerEl.insertBefore(desired, ptr);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Remove leftover children not in nextEls
|
|
295
|
+
while (ptr) {
|
|
296
|
+
const next = ptr.nextSibling;
|
|
297
|
+
containerEl.removeChild(ptr);
|
|
298
|
+
ptr = next;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Clean map: remove keys not in nextKeys
|
|
302
|
+
// Iterate over elementsByKey entries and delete if not in nextKeys
|
|
303
|
+
if (elementsByKey.size !== nextKeys.size) {
|
|
304
|
+
for (const k of elementsByKey.keys()) {
|
|
305
|
+
if (!nextKeys.has(k)) {
|
|
306
|
+
elementsByKey.delete(k);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Restore state after DOM manipulation
|
|
312
|
+
if (restoreFocus) restoreFocus();
|
|
313
|
+
if (restoreScroll) restoreScroll();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Initial render
|
|
317
|
+
updateList();
|
|
318
|
+
|
|
319
|
+
// Subscription
|
|
320
|
+
let unsubscribe;
|
|
321
|
+
if (typeof store.$subscribe === 'function') {
|
|
322
|
+
unsubscribe = store.$subscribe(arrayKey, updateList);
|
|
323
|
+
} else if (typeof store.subscribe === 'function') {
|
|
324
|
+
// Generic subscribe (e.g. computed)
|
|
325
|
+
unsubscribe = store.subscribe(() => updateList());
|
|
326
|
+
} else {
|
|
327
|
+
console.warn('[Lume.js] repeat(): store is not reactive (no $subscribe or subscribe method)');
|
|
328
|
+
return () => {
|
|
329
|
+
containerEl.replaceChildren();
|
|
330
|
+
elementsByKey.clear();
|
|
331
|
+
seenKeys.clear();
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
unsubscribe();
|
|
337
|
+
// Clear DOM elements (replaceChildren is faster than loop)
|
|
338
|
+
containerEl.replaceChildren();
|
|
339
|
+
elementsByKey.clear();
|
|
340
|
+
seenKeys.clear();
|
|
341
|
+
};
|
|
342
|
+
}
|
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