lume-js 0.4.1 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lume-js",
3
- "version": "0.4.1",
3
+ "version": "1.0.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",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "dev": "vite",
21
+ "dev:site": "node scripts/build-site-assets.js && vite -c vite.site.config.js",
21
22
  "build": "echo 'No build step needed - zero-runtime library!'",
22
23
  "size": "node scripts/check-size.js",
23
24
  "test": "vitest run",
@@ -57,12 +58,19 @@
57
58
  "bugs": {
58
59
  "url": "https://github.com/sathvikc/lume-js/issues"
59
60
  },
60
- "homepage": "https://github.com/sathvikc/lume-js#readme",
61
+ "homepage": "https://sathvikc.github.io/lume-js/",
61
62
  "devDependencies": {
62
- "vite": "^7.1.9",
63
- "vitest": "^2.1.4",
63
+ "@tailwindcss/postcss": "^4.1.17",
64
+ "@tailwindcss/typography": "^0.5.19",
64
65
  "@vitest/coverage-v8": "^2.1.4",
65
- "jsdom": "^25.0.1"
66
+ "autoprefixer": "^10.4.22",
67
+ "highlight.js": "^11.11.1",
68
+ "jsdom": "^25.0.1",
69
+ "marked": "^17.0.1",
70
+ "postcss": "^8.5.6",
71
+ "tailwindcss": "^4.1.17",
72
+ "vite": "^7.1.9",
73
+ "vitest": "^2.1.4"
66
74
  },
67
75
  "engines": {
68
76
  "node": ">=20.19.0"
@@ -83,3 +83,176 @@ export function watch<T extends object, K extends keyof T>(
83
83
  key: K,
84
84
  callback: Subscriber<T[K]>
85
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
+ }