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 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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
- [![Version](https://img.shields.io/badge/version-0.4.0-green.svg)](package.json)
8
+ [![Version](https://img.shields.io/badge/version-0.5.0-green.svg)](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
- - 37 tests covering core behavior, addons, inputs (text/checkbox/radio/number/range/select/textarea), nested state, and cleanup semantics
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. **Check:** Project specification for philosophy
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.4.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;
@@ -1,2 +1,3 @@
1
1
  export { computed } from "./computed.js";
2
- export { watch } from "./watch.js";
2
+ export { watch } from "./watch.js";
3
+ export { repeat } from "./repeat.js";
@@ -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
+ }
@@ -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 nodes = root.querySelectorAll("[data-bind]");
38
- const cleanups = [];
48
+ const { immediate = false } = options;
39
49
 
40
- nodes.forEach(el => {
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
- }
50
+ // Core binding logic extracted to separate function
51
+ const performBinding = () => {
52
+ const nodes = root.querySelectorAll("[data-bind]");
53
+ const cleanups = [];
47
54
 
48
- const pathArr = bindPath.split(".");
49
- const lastKey = pathArr.pop();
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
- let target;
52
- try {
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
- if (!target || typeof target.$subscribe !== 'function') {
60
- console.warn(`[Lume.js] Target for "${bindPath}" is not a reactive state object`);
61
- return;
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
- // Subscribe to changes - receives already-batched notifications
65
- const unsubscribe = target.$subscribe(lastKey, val => {
66
- updateElement(el, val);
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
- return () => {
81
- cleanups.forEach(cleanup => cleanup());
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].forEach(fn => fn(value));
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
@@ -10,6 +10,6 @@
10
10
  * import { state, bindDom, effect } from "lume-js";
11
11
  */
12
12
 
13
- export { state } from "./core/state.js";
13
+ export { state, isReactive } from "./core/state.js";
14
14
  export { bindDom } from "./core/bindDom.js";
15
15
  export { effect } from "./core/effect.js";