eleva 1.0.0-rc.11 → 1.0.0-rc.13
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 +15 -15
- package/dist/eleva-plugins.cjs.js +1763 -2160
- package/dist/eleva-plugins.cjs.js.map +1 -1
- package/dist/eleva-plugins.esm.js +1763 -2160
- package/dist/eleva-plugins.esm.js.map +1 -1
- package/dist/eleva-plugins.umd.js +1763 -2160
- package/dist/eleva-plugins.umd.js.map +1 -1
- package/dist/eleva-plugins.umd.min.js +1 -2
- package/dist/eleva-plugins.umd.min.js.map +1 -1
- package/dist/eleva.cjs.js +626 -783
- package/dist/eleva.cjs.js.map +1 -1
- package/dist/eleva.d.ts +108 -92
- package/dist/eleva.esm.js +626 -783
- package/dist/eleva.esm.js.map +1 -1
- package/dist/eleva.umd.js +626 -783
- package/dist/eleva.umd.js.map +1 -1
- package/dist/eleva.umd.min.js +1 -2
- package/dist/eleva.umd.min.js.map +1 -1
- package/dist/plugins/attr.umd.js +117 -153
- package/dist/plugins/attr.umd.js.map +1 -1
- package/dist/plugins/attr.umd.min.js +1 -2
- package/dist/plugins/attr.umd.min.js.map +1 -1
- package/dist/plugins/props.umd.js +346 -398
- package/dist/plugins/props.umd.js.map +1 -1
- package/dist/plugins/props.umd.min.js +1 -2
- package/dist/plugins/props.umd.min.js.map +1 -1
- package/dist/plugins/router.umd.js +889 -1114
- package/dist/plugins/router.umd.js.map +1 -1
- package/dist/plugins/router.umd.min.js +1 -2
- package/dist/plugins/router.umd.min.js.map +1 -1
- package/dist/plugins/store.umd.js +412 -496
- package/dist/plugins/store.umd.js.map +1 -1
- package/dist/plugins/store.umd.min.js +1 -2
- package/dist/plugins/store.umd.min.js.map +1 -1
- package/package.json +5 -5
- package/src/core/Eleva.js +24 -6
- package/src/modules/Emitter.js +5 -6
- package/src/modules/Renderer.js +182 -160
- package/src/modules/Signal.js +16 -28
- package/src/modules/TemplateEngine.js +21 -2
- package/src/plugins/Attr.js +7 -12
- package/src/plugins/Props.js +29 -20
- package/src/plugins/Router.js +1 -1
- package/src/plugins/Store.js +1 -1
- package/types/core/Eleva.d.ts +3 -2
- package/types/core/Eleva.d.ts.map +1 -1
- package/types/modules/Emitter.d.ts.map +1 -1
- package/types/modules/Renderer.d.ts +85 -88
- package/types/modules/Renderer.d.ts.map +1 -1
- package/types/modules/Signal.d.ts +11 -16
- package/types/modules/Signal.d.ts.map +1 -1
- package/types/modules/TemplateEngine.d.ts +10 -1
- package/types/modules/TemplateEngine.d.ts.map +1 -1
- package/types/plugins/Attr.d.ts.map +1 -1
- package/types/plugins/Props.d.ts.map +1 -1
package/dist/eleva.umd.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Eleva v1.0.0-rc.
|
|
1
|
+
/*! Eleva v1.0.0-rc.13 | MIT License | https://elevajs.com */
|
|
2
2
|
(function (global, factory) {
|
|
3
3
|
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
|
4
4
|
typeof define === 'function' && define.amd ? define(factory) :
|
|
@@ -8,28 +8,19 @@
|
|
|
8
8
|
// ============================================================================
|
|
9
9
|
// TYPE DEFINITIONS - TypeScript-friendly JSDoc types for IDE support
|
|
10
10
|
// ============================================================================
|
|
11
|
-
|
|
12
11
|
/**
|
|
13
12
|
* @typedef {Record<string, unknown>} TemplateData
|
|
14
13
|
* Data context for template interpolation
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
14
|
+
*/ /**
|
|
18
15
|
* @typedef {string} TemplateString
|
|
19
16
|
* A string containing {{ expression }} interpolation markers
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/**
|
|
17
|
+
*/ /**
|
|
23
18
|
* @typedef {string} Expression
|
|
24
19
|
* A JavaScript expression to be evaluated in the data context
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/**
|
|
20
|
+
*/ /**
|
|
28
21
|
* @typedef {unknown} EvaluationResult
|
|
29
22
|
* The result of evaluating an expression (string, number, boolean, object, etc.)
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
/**
|
|
23
|
+
*/ /**
|
|
33
24
|
* @class 🔒 TemplateEngine
|
|
34
25
|
* @classdesc A secure template engine that handles interpolation and dynamic attribute parsing.
|
|
35
26
|
* Provides a way to evaluate expressions in templates.
|
|
@@ -69,19 +60,8 @@
|
|
|
69
60
|
* const data = { count: { value: 42 } };
|
|
70
61
|
* const result = TemplateEngine.parse(template, data);
|
|
71
62
|
* // Result: "Count: 42"
|
|
72
|
-
*/
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Regular expression for matching template expressions in the format {{ expression }}
|
|
76
|
-
* Matches: {{ anything }} with optional whitespace inside braces
|
|
77
|
-
*
|
|
78
|
-
* @static
|
|
79
|
-
* @private
|
|
80
|
-
* @type {RegExp}
|
|
81
|
-
*/
|
|
82
|
-
static expressionPattern = /\{\{\s*(.*?)\s*\}\}/g;
|
|
83
|
-
|
|
84
|
-
/**
|
|
63
|
+
*/ class TemplateEngine {
|
|
64
|
+
/**
|
|
85
65
|
* Parses a template string, replacing expressions with their evaluated values.
|
|
86
66
|
* Expressions are evaluated in the provided data context.
|
|
87
67
|
*
|
|
@@ -118,13 +98,11 @@
|
|
|
118
98
|
* online: true
|
|
119
99
|
* });
|
|
120
100
|
* // Result: "Status: Active"
|
|
121
|
-
*/
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
/**
|
|
101
|
+
*/ static parse(template, data) {
|
|
102
|
+
if (typeof template !== "string") return template;
|
|
103
|
+
return template.replace(this.expressionPattern, (_, expression)=>this.evaluate(expression, data));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
128
106
|
* Evaluates an expression in the context of the provided data object.
|
|
129
107
|
*
|
|
130
108
|
* Note: This does not provide a true sandbox and evaluated expressions may access global scope.
|
|
@@ -135,7 +113,7 @@
|
|
|
135
113
|
* @static
|
|
136
114
|
* @param {Expression|unknown} expression - The expression to evaluate.
|
|
137
115
|
* @param {TemplateData} data - The data context for evaluation.
|
|
138
|
-
* @returns {EvaluationResult} The result of the evaluation, or
|
|
116
|
+
* @returns {EvaluationResult} The result of the evaluation, or empty string if evaluation fails.
|
|
139
117
|
*
|
|
140
118
|
* @example
|
|
141
119
|
* // Property access
|
|
@@ -164,46 +142,64 @@
|
|
|
164
142
|
* // Failed evaluation returns empty string
|
|
165
143
|
* TemplateEngine.evaluate("nonexistent.property", {});
|
|
166
144
|
* // Result: ""
|
|
167
|
-
*/
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
145
|
+
*/ static evaluate(expression, data) {
|
|
146
|
+
if (typeof expression !== "string") return expression;
|
|
147
|
+
let fn = this._functionCache.get(expression);
|
|
148
|
+
if (!fn) {
|
|
149
|
+
try {
|
|
150
|
+
fn = new Function("data", `with(data) { return ${expression}; }`);
|
|
151
|
+
this._functionCache.set(expression, fn);
|
|
152
|
+
} catch {
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return fn(data);
|
|
158
|
+
} catch {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
174
161
|
}
|
|
175
|
-
}
|
|
176
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Regular expression for matching template expressions in the format {{ expression }}
|
|
165
|
+
* Matches: {{ anything }} with optional whitespace inside braces
|
|
166
|
+
*
|
|
167
|
+
* @static
|
|
168
|
+
* @private
|
|
169
|
+
* @type {RegExp}
|
|
170
|
+
*/ TemplateEngine.expressionPattern = /\{\{\s*(.*?)\s*\}\}/g;
|
|
171
|
+
/**
|
|
172
|
+
* Cache for compiled expression functions.
|
|
173
|
+
* Stores compiled Function objects keyed by expression string for O(1) lookup.
|
|
174
|
+
*
|
|
175
|
+
* @static
|
|
176
|
+
* @private
|
|
177
|
+
* @type {Map<string, Function>}
|
|
178
|
+
*/ TemplateEngine._functionCache = new Map();
|
|
177
179
|
|
|
178
180
|
// ============================================================================
|
|
179
181
|
// TYPE DEFINITIONS - TypeScript-friendly JSDoc types for IDE support
|
|
180
182
|
// ============================================================================
|
|
181
|
-
|
|
182
183
|
/**
|
|
183
184
|
* @template T
|
|
184
185
|
* @callback SignalWatcher
|
|
185
186
|
* @param {T} value - The new value of the signal
|
|
186
187
|
* @returns {void}
|
|
187
|
-
*/
|
|
188
|
-
|
|
189
|
-
/**
|
|
188
|
+
*/ /**
|
|
190
189
|
* @callback SignalUnsubscribe
|
|
191
190
|
* @returns {boolean} True if the watcher was successfully removed
|
|
192
|
-
*/
|
|
193
|
-
|
|
194
|
-
/**
|
|
191
|
+
*/ /**
|
|
195
192
|
* @template T
|
|
196
193
|
* @typedef {Object} SignalLike
|
|
197
194
|
* @property {T} value - The current value
|
|
198
195
|
* @property {function(SignalWatcher<T>): SignalUnsubscribe} watch - Subscribe to changes
|
|
199
|
-
*/
|
|
200
|
-
|
|
201
|
-
/**
|
|
196
|
+
*/ /**
|
|
202
197
|
* @class ⚡ Signal
|
|
203
198
|
* @classdesc A reactive data holder that enables fine-grained reactivity in the Eleva framework.
|
|
204
|
-
* Signals notify registered watchers when their value changes, enabling efficient
|
|
205
|
-
* through targeted patching rather than full re-renders.
|
|
206
|
-
*
|
|
199
|
+
* Signals notify registered watchers synchronously when their value changes, enabling efficient
|
|
200
|
+
* DOM updates through targeted patching rather than full re-renders.
|
|
201
|
+
* Synchronous notification preserves stack traces and allows immediate value inspection.
|
|
202
|
+
* Render batching is handled at the component level, not the signal level.
|
|
207
203
|
* The class is generic, allowing type-safe handling of any value type T.
|
|
208
204
|
*
|
|
209
205
|
* @template T The type of value held by this signal
|
|
@@ -229,74 +225,29 @@
|
|
|
229
225
|
* position.value = { x: 10, y: 20 }; // Triggers watchers
|
|
230
226
|
*
|
|
231
227
|
* @implements {SignalLike<T>}
|
|
232
|
-
*/
|
|
233
|
-
class Signal {
|
|
234
|
-
/**
|
|
235
|
-
* Creates a new Signal instance with the specified initial value.
|
|
236
|
-
*
|
|
237
|
-
* @public
|
|
238
|
-
* @param {T} value - The initial value of the signal.
|
|
239
|
-
*
|
|
240
|
-
* @example
|
|
241
|
-
* // Primitive types
|
|
242
|
-
* const count = new Signal(0); // Signal<number>
|
|
243
|
-
* const name = new Signal("John"); // Signal<string>
|
|
244
|
-
* const active = new Signal(true); // Signal<boolean>
|
|
245
|
-
*
|
|
246
|
-
* @example
|
|
247
|
-
* // Complex types (use JSDoc for type inference)
|
|
248
|
-
* /** @type {Signal<string[]>} *\/
|
|
249
|
-
* const items = new Signal([]);
|
|
250
|
-
*
|
|
251
|
-
* /** @type {Signal<{id: number, name: string} | null>} *\/
|
|
252
|
-
* const user = new Signal(null);
|
|
253
|
-
*/
|
|
254
|
-
constructor(value) {
|
|
228
|
+
*/ class Signal {
|
|
255
229
|
/**
|
|
256
|
-
* Internal storage for the signal's current value
|
|
257
|
-
* @private
|
|
258
|
-
* @type {T}
|
|
259
|
-
*/
|
|
260
|
-
this._value = value;
|
|
261
|
-
/**
|
|
262
|
-
* Collection of callback functions to be notified when value changes
|
|
263
|
-
* @private
|
|
264
|
-
* @type {Set<SignalWatcher<T>>}
|
|
265
|
-
*/
|
|
266
|
-
this._watchers = new Set();
|
|
267
|
-
/**
|
|
268
|
-
* Flag to prevent multiple synchronous watcher notifications
|
|
269
|
-
* @private
|
|
270
|
-
* @type {boolean}
|
|
271
|
-
*/
|
|
272
|
-
this._pending = false;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
230
|
* Gets the current value of the signal.
|
|
277
231
|
*
|
|
278
232
|
* @public
|
|
279
233
|
* @returns {T} The current value.
|
|
280
|
-
*/
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
* Sets a new value for the signal and notifies all registered watchers if the value has changed.
|
|
287
|
-
* The notification is batched using microtasks to prevent multiple synchronous updates.
|
|
234
|
+
*/ get value() {
|
|
235
|
+
return this._value;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Sets a new value for the signal and synchronously notifies all registered watchers if the value has changed.
|
|
239
|
+
* Synchronous notification preserves stack traces and ensures immediate value consistency.
|
|
288
240
|
*
|
|
289
241
|
* @public
|
|
290
242
|
* @param {T} newVal - The new value to set.
|
|
291
243
|
* @returns {void}
|
|
292
|
-
*/
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
/**
|
|
244
|
+
*/ set value(newVal) {
|
|
245
|
+
if (this._value !== newVal) {
|
|
246
|
+
this._value = newVal;
|
|
247
|
+
this._notify();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
300
251
|
* Registers a watcher function that will be called whenever the signal's value changes.
|
|
301
252
|
* The watcher will receive the new value as its argument.
|
|
302
253
|
*
|
|
@@ -317,60 +268,73 @@
|
|
|
317
268
|
* const unsub1 = signal.watch((v) => console.log("Watcher 1:", v));
|
|
318
269
|
* const unsub2 = signal.watch((v) => console.log("Watcher 2:", v));
|
|
319
270
|
* signal.value = "test"; // Both watchers are called
|
|
320
|
-
*/
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
*
|
|
328
|
-
* Uses a pending flag to batch multiple synchronous updates into a single notification.
|
|
329
|
-
* All watcher callbacks receive the current value when executed.
|
|
271
|
+
*/ watch(fn) {
|
|
272
|
+
this._watchers.add(fn);
|
|
273
|
+
return ()=>this._watchers.delete(fn);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Synchronously notifies all registered watchers of the value change.
|
|
277
|
+
* This preserves stack traces for debugging and ensures immediate
|
|
278
|
+
* value consistency. Render batching is handled at the component level.
|
|
330
279
|
*
|
|
331
280
|
* @private
|
|
332
281
|
* @returns {void}
|
|
333
|
-
*/
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
282
|
+
*/ _notify() {
|
|
283
|
+
for (const fn of this._watchers)fn(this._value);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Creates a new Signal instance with the specified initial value.
|
|
287
|
+
*
|
|
288
|
+
* @public
|
|
289
|
+
* @param {T} value - The initial value of the signal.
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* // Primitive types
|
|
293
|
+
* const count = new Signal(0); // Signal<number>
|
|
294
|
+
* const name = new Signal("John"); // Signal<string>
|
|
295
|
+
* const active = new Signal(true); // Signal<boolean>
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* // Complex types (use JSDoc for type inference)
|
|
299
|
+
* /** @type {Signal<string[]>} *\/
|
|
300
|
+
* const items = new Signal([]);
|
|
301
|
+
*
|
|
302
|
+
* /** @type {Signal<{id: number, name: string} | null>} *\/
|
|
303
|
+
* const user = new Signal(null);
|
|
304
|
+
*/ constructor(value){
|
|
305
|
+
/**
|
|
306
|
+
* Internal storage for the signal's current value.
|
|
307
|
+
* @private
|
|
308
|
+
* @type {T}
|
|
309
|
+
*/ this._value = value;
|
|
310
|
+
/**
|
|
311
|
+
* Collection of callback functions to be notified when value changes.
|
|
312
|
+
* @private
|
|
313
|
+
* @type {Set<SignalWatcher<T>>}
|
|
314
|
+
*/ this._watchers = new Set();
|
|
315
|
+
}
|
|
343
316
|
}
|
|
344
317
|
|
|
345
318
|
// ============================================================================
|
|
346
319
|
// TYPE DEFINITIONS - TypeScript-friendly JSDoc types for IDE support
|
|
347
320
|
// ============================================================================
|
|
348
|
-
|
|
349
321
|
/**
|
|
350
322
|
* @template T
|
|
351
323
|
* @callback EventHandler
|
|
352
324
|
* @param {...T} args - Event arguments
|
|
353
325
|
* @returns {void|Promise<void>}
|
|
354
|
-
*/
|
|
355
|
-
|
|
356
|
-
/**
|
|
326
|
+
*/ /**
|
|
357
327
|
* @callback EventUnsubscribe
|
|
358
328
|
* @returns {void}
|
|
359
|
-
*/
|
|
360
|
-
|
|
361
|
-
/**
|
|
329
|
+
*/ /**
|
|
362
330
|
* @typedef {`${string}:${string}`} EventName
|
|
363
331
|
* Event names follow the format 'namespace:action' (e.g., 'user:login', 'cart:update')
|
|
364
|
-
*/
|
|
365
|
-
|
|
366
|
-
/**
|
|
332
|
+
*/ /**
|
|
367
333
|
* @typedef {Object} EmitterLike
|
|
368
334
|
* @property {function(string, EventHandler<unknown>): EventUnsubscribe} on - Subscribe to an event
|
|
369
335
|
* @property {function(string, EventHandler<unknown>=): void} off - Unsubscribe from an event
|
|
370
336
|
* @property {function(string, ...unknown): void} emit - Emit an event
|
|
371
|
-
*/
|
|
372
|
-
|
|
373
|
-
/**
|
|
337
|
+
*/ /**
|
|
374
338
|
* @class 📡 Emitter
|
|
375
339
|
* @classdesc A robust event emitter that enables inter-component communication through a publish-subscribe pattern.
|
|
376
340
|
* Components can emit events and listen for events from other components, facilitating loose coupling
|
|
@@ -413,26 +377,8 @@
|
|
|
413
377
|
* emitter.on('router:navigate', (to, from) => {});
|
|
414
378
|
*
|
|
415
379
|
* @implements {EmitterLike}
|
|
416
|
-
*/
|
|
417
|
-
class Emitter {
|
|
418
|
-
/**
|
|
419
|
-
* Creates a new Emitter instance.
|
|
420
|
-
*
|
|
421
|
-
* @public
|
|
422
|
-
*
|
|
423
|
-
* @example
|
|
424
|
-
* const emitter = new Emitter();
|
|
425
|
-
*/
|
|
426
|
-
constructor() {
|
|
380
|
+
*/ class Emitter {
|
|
427
381
|
/**
|
|
428
|
-
* Map of event names to their registered handler functions
|
|
429
|
-
* @private
|
|
430
|
-
* @type {Map<string, Set<EventHandler<unknown>>>}
|
|
431
|
-
*/
|
|
432
|
-
this._events = new Map();
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
382
|
* Registers an event handler for the specified event name.
|
|
437
383
|
* The handler will be called with the event data when the event is emitted.
|
|
438
384
|
* Event names should follow the format 'namespace:action' for consistency.
|
|
@@ -456,14 +402,13 @@
|
|
|
456
402
|
* @example
|
|
457
403
|
* // Cleanup
|
|
458
404
|
* unsubscribe(); // Stops listening for the event
|
|
459
|
-
*/
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
/**
|
|
405
|
+
*/ on(event, handler) {
|
|
406
|
+
let h = this._events.get(event);
|
|
407
|
+
if (!h) this._events.set(event, h = new Set());
|
|
408
|
+
h.add(handler);
|
|
409
|
+
return ()=>this.off(event, handler);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
467
412
|
* Removes an event handler for the specified event name.
|
|
468
413
|
* If no handler is provided, all handlers for the event are removed.
|
|
469
414
|
* Automatically cleans up empty event sets to prevent memory leaks.
|
|
@@ -483,20 +428,17 @@
|
|
|
483
428
|
* @example
|
|
484
429
|
* // Remove all handlers for an event
|
|
485
430
|
* emitter.off('user:login');
|
|
486
|
-
*/
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
this._events.delete(event);
|
|
431
|
+
*/ off(event, handler) {
|
|
432
|
+
if (!this._events.has(event)) return;
|
|
433
|
+
if (handler) {
|
|
434
|
+
const handlers = this._events.get(event);
|
|
435
|
+
handlers.delete(handler);
|
|
436
|
+
if (handlers.size === 0) this._events.delete(event);
|
|
437
|
+
} else {
|
|
438
|
+
this._events.delete(event);
|
|
439
|
+
}
|
|
496
440
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
/**
|
|
441
|
+
/**
|
|
500
442
|
* Emits an event with the specified data to all registered handlers.
|
|
501
443
|
* Handlers are called synchronously in the order they were registered.
|
|
502
444
|
* If no handlers are registered for the event, the emission is silently ignored.
|
|
@@ -518,313 +460,326 @@
|
|
|
518
460
|
* @example
|
|
519
461
|
* // Emit without data
|
|
520
462
|
* emitter.emit('app:ready');
|
|
521
|
-
*/
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
463
|
+
*/ emit(event, ...args) {
|
|
464
|
+
const handlers = this._events.get(event);
|
|
465
|
+
if (handlers) for (const handler of handlers)handler(...args);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Creates a new Emitter instance.
|
|
469
|
+
*
|
|
470
|
+
* @public
|
|
471
|
+
*
|
|
472
|
+
* @example
|
|
473
|
+
* const emitter = new Emitter();
|
|
474
|
+
*/ constructor(){
|
|
475
|
+
/**
|
|
476
|
+
* Map of event names to their registered handler functions
|
|
477
|
+
* @private
|
|
478
|
+
* @type {Map<string, Set<EventHandler<unknown>>>}
|
|
479
|
+
*/ this._events = new Map();
|
|
480
|
+
}
|
|
526
481
|
}
|
|
527
482
|
|
|
528
483
|
// ============================================================================
|
|
529
484
|
// TYPE DEFINITIONS - TypeScript-friendly JSDoc types for IDE support
|
|
530
485
|
// ============================================================================
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* @typedef {Object} PatchOptions
|
|
534
|
-
* @property {boolean} [preserveStyles=true]
|
|
535
|
-
* Whether to preserve style elements with data-e-style attribute
|
|
536
|
-
* @property {boolean} [preserveInstances=true]
|
|
537
|
-
* Whether to preserve elements with _eleva_instance property
|
|
538
|
-
*/
|
|
539
|
-
|
|
540
486
|
/**
|
|
541
487
|
* @typedef {Map<string, Node>} KeyMap
|
|
542
|
-
*
|
|
543
|
-
*/
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
*
|
|
548
|
-
|
|
549
|
-
|
|
488
|
+
* Map of key attribute values to their corresponding DOM nodes for O(1) lookup
|
|
489
|
+
*/ /**
|
|
490
|
+
* @typedef {Object} RendererLike
|
|
491
|
+
* @property {function(HTMLElement, string): void} patchDOM - Patches the DOM with new HTML
|
|
492
|
+
*/ /**
|
|
493
|
+
* Properties that can diverge from attributes via user interaction.
|
|
494
|
+
* @private
|
|
495
|
+
* @type {string[]}
|
|
496
|
+
*/ const SYNC_PROPS = [
|
|
497
|
+
"value",
|
|
498
|
+
"checked",
|
|
499
|
+
"selected"
|
|
500
|
+
];
|
|
550
501
|
/**
|
|
551
502
|
* @class 🎨 Renderer
|
|
552
|
-
* @classdesc A high-performance DOM renderer that implements an optimized
|
|
503
|
+
* @classdesc A high-performance DOM renderer that implements an optimized two-pointer diffing
|
|
504
|
+
* algorithm with key-based node reconciliation. The renderer efficiently updates the DOM by
|
|
505
|
+
* computing the minimal set of operations needed to transform the current state to the desired state.
|
|
553
506
|
*
|
|
554
507
|
* Key features:
|
|
555
|
-
* -
|
|
556
|
-
* - Key-based node reconciliation for optimal performance
|
|
557
|
-
* -
|
|
558
|
-
* -
|
|
559
|
-
* -
|
|
560
|
-
*
|
|
561
|
-
* The renderer is designed to minimize DOM operations while maintaining
|
|
562
|
-
* exact attribute synchronization and proper node identity preservation.
|
|
563
|
-
* It's particularly optimized for frequent updates and complex DOM structures.
|
|
508
|
+
* - Two-pointer diffing algorithm for efficient DOM updates
|
|
509
|
+
* - Key-based node reconciliation for optimal list performance (O(1) lookup)
|
|
510
|
+
* - Preserves DOM node identity during reordering (maintains event listeners, focus, animations)
|
|
511
|
+
* - Intelligent attribute synchronization (skips Eleva event attributes)
|
|
512
|
+
* - Preservation of Eleva-managed component instances and style elements
|
|
564
513
|
*
|
|
565
514
|
* @example
|
|
566
515
|
* // Basic usage
|
|
567
516
|
* const renderer = new Renderer();
|
|
568
|
-
*
|
|
569
|
-
* const newHtml = "<div>Updated content</div>";
|
|
570
|
-
* renderer.patchDOM(container, newHtml);
|
|
517
|
+
* renderer.patchDOM(container, '<div>Updated content</div>');
|
|
571
518
|
*
|
|
572
519
|
* @example
|
|
573
520
|
* // With keyed elements for optimal list updates
|
|
574
|
-
* const
|
|
575
|
-
*
|
|
576
|
-
* <li key="item-1">First</li>
|
|
577
|
-
* <li key="item-2">Second</li>
|
|
578
|
-
* <li key="item-3">Third</li>
|
|
579
|
-
* </ul>
|
|
580
|
-
* `;
|
|
581
|
-
* renderer.patchDOM(container, listHtml);
|
|
521
|
+
* const html = items.map(item => `<li key="${item.id}">${item.name}</li>`).join('');
|
|
522
|
+
* renderer.patchDOM(listContainer, `<ul>${html}</ul>`);
|
|
582
523
|
*
|
|
583
524
|
* @example
|
|
584
|
-
* //
|
|
585
|
-
* //
|
|
586
|
-
* //
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
*
|
|
592
|
-
* @public
|
|
593
|
-
*
|
|
594
|
-
* @example
|
|
595
|
-
* const renderer = new Renderer();
|
|
596
|
-
*/
|
|
597
|
-
constructor() {
|
|
525
|
+
* // Keyed elements preserve DOM identity during reordering
|
|
526
|
+
* // Before: [A, B, C] -> After: [C, A, B]
|
|
527
|
+
* // The actual DOM nodes are moved, not recreated
|
|
528
|
+
* renderer.patchDOM(container, '<div key="C">C</div><div key="A">A</div><div key="B">B</div>');
|
|
529
|
+
*
|
|
530
|
+
* @implements {RendererLike}
|
|
531
|
+
*/ class Renderer {
|
|
598
532
|
/**
|
|
599
|
-
* A temporary container to hold the new HTML content while diffing.
|
|
600
|
-
* Reused across patch operations to minimize memory allocation.
|
|
601
|
-
* @private
|
|
602
|
-
* @type {HTMLDivElement}
|
|
603
|
-
*/
|
|
604
|
-
this._tempContainer = document.createElement("div");
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
533
|
* Patches the DOM of the given container with the provided HTML string.
|
|
609
|
-
* Uses an optimized diffing algorithm to minimize DOM operations.
|
|
534
|
+
* Uses an optimized two-pointer diffing algorithm to minimize DOM operations.
|
|
535
|
+
* The algorithm computes the minimal set of insertions, deletions, and updates
|
|
536
|
+
* needed to transform the current DOM state to match the new HTML.
|
|
610
537
|
*
|
|
611
538
|
* @public
|
|
612
539
|
* @param {HTMLElement} container - The container element to patch.
|
|
613
|
-
* @param {string} newHtml - The new HTML string.
|
|
540
|
+
* @param {string} newHtml - The new HTML string to render.
|
|
614
541
|
* @returns {void}
|
|
615
|
-
* @throws {TypeError} If container is not an HTMLElement or newHtml is not a string.
|
|
616
|
-
* @throws {Error} If DOM patching fails.
|
|
617
542
|
*
|
|
618
543
|
* @example
|
|
619
|
-
* //
|
|
544
|
+
* // Simple content update
|
|
620
545
|
* renderer.patchDOM(container, '<div class="updated">New content</div>');
|
|
621
546
|
*
|
|
622
547
|
* @example
|
|
623
|
-
* //
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
* renderer.patchDOM(
|
|
629
|
-
*/
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
throw new TypeError("Container must be an HTMLElement");
|
|
633
|
-
}
|
|
634
|
-
if (typeof newHtml !== "string") {
|
|
635
|
-
throw new TypeError("newHtml must be a string");
|
|
636
|
-
}
|
|
637
|
-
try {
|
|
638
|
-
this._tempContainer.innerHTML = newHtml;
|
|
639
|
-
this._diff(container, this._tempContainer);
|
|
640
|
-
} catch (error) {
|
|
641
|
-
throw new Error(`Failed to patch DOM: ${error.message}`);
|
|
548
|
+
* // List with keyed items (optimal for reordering)
|
|
549
|
+
* renderer.patchDOM(container, '<ul><li key="1">First</li><li key="2">Second</li></ul>');
|
|
550
|
+
*
|
|
551
|
+
* @example
|
|
552
|
+
* // Empty the container
|
|
553
|
+
* renderer.patchDOM(container, '');
|
|
554
|
+
*/ patchDOM(container, newHtml) {
|
|
555
|
+
this._tempContainer.innerHTML = newHtml;
|
|
556
|
+
this._diff(container, this._tempContainer);
|
|
642
557
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
/**
|
|
558
|
+
/**
|
|
646
559
|
* Performs a diff between two DOM nodes and patches the old node to match the new node.
|
|
560
|
+
* Uses a two-pointer algorithm with key-based reconciliation for optimal performance.
|
|
561
|
+
*
|
|
562
|
+
* Algorithm overview:
|
|
563
|
+
* 1. Compare children from start using two pointers
|
|
564
|
+
* 2. For mismatches, build a key map lazily for O(1) lookup
|
|
565
|
+
* 3. Move or insert nodes as needed
|
|
566
|
+
* 4. Clean up remaining nodes at the end
|
|
647
567
|
*
|
|
648
568
|
* @private
|
|
649
|
-
* @param {HTMLElement} oldParent - The original DOM element.
|
|
650
|
-
* @param {HTMLElement} newParent - The new DOM element.
|
|
569
|
+
* @param {HTMLElement} oldParent - The original DOM element to update.
|
|
570
|
+
* @param {HTMLElement} newParent - The new DOM element with desired state.
|
|
651
571
|
* @returns {void}
|
|
652
|
-
*/
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
572
|
+
*/ _diff(oldParent, newParent) {
|
|
573
|
+
// Early exit for leaf nodes (no children)
|
|
574
|
+
if (!oldParent.firstChild && !newParent.firstChild) return;
|
|
575
|
+
const oldChildren = Array.from(oldParent.childNodes);
|
|
576
|
+
const newChildren = Array.from(newParent.childNodes);
|
|
577
|
+
let oldStart = 0, newStart = 0;
|
|
578
|
+
let oldEnd = oldChildren.length - 1;
|
|
579
|
+
let newEnd = newChildren.length - 1;
|
|
580
|
+
let keyMap = null;
|
|
581
|
+
// Two-pointer algorithm with key-based reconciliation
|
|
582
|
+
while(oldStart <= oldEnd && newStart <= newEnd){
|
|
583
|
+
const oldNode = oldChildren[oldStart];
|
|
584
|
+
const newNode = newChildren[newStart];
|
|
585
|
+
if (!oldNode) {
|
|
586
|
+
oldStart++;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (this._isSameNode(oldNode, newNode)) {
|
|
590
|
+
this._patchNode(oldNode, newNode);
|
|
591
|
+
oldStart++;
|
|
592
|
+
newStart++;
|
|
593
|
+
} else {
|
|
594
|
+
// Build key map lazily for O(1) lookup
|
|
595
|
+
if (!keyMap) {
|
|
596
|
+
keyMap = this._createKeyMap(oldChildren, oldStart, oldEnd);
|
|
597
|
+
}
|
|
598
|
+
const key = this._getNodeKey(newNode);
|
|
599
|
+
const matchedNode = key ? keyMap.get(key) : null;
|
|
600
|
+
// Only use matched node if tag also matches
|
|
601
|
+
if (matchedNode && matchedNode.nodeName === newNode.nodeName) {
|
|
602
|
+
// Move existing keyed node (preserves DOM identity)
|
|
603
|
+
this._patchNode(matchedNode, newNode);
|
|
604
|
+
oldParent.insertBefore(matchedNode, oldNode);
|
|
605
|
+
oldChildren[oldChildren.indexOf(matchedNode)] = null;
|
|
606
|
+
} else {
|
|
607
|
+
// Insert new node
|
|
608
|
+
oldParent.insertBefore(newNode.cloneNode(true), oldNode);
|
|
609
|
+
}
|
|
610
|
+
newStart++;
|
|
611
|
+
}
|
|
674
612
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
613
|
+
// Add remaining new nodes
|
|
614
|
+
if (oldStart > oldEnd) {
|
|
615
|
+
const refNode = newChildren[newEnd + 1] ? oldChildren[oldStart] : null;
|
|
616
|
+
for(let i = newStart; i <= newEnd; i++){
|
|
617
|
+
if (newChildren[i]) {
|
|
618
|
+
oldParent.insertBefore(newChildren[i].cloneNode(true), refNode);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
} else if (newStart > newEnd) {
|
|
622
|
+
for(let i = oldStart; i <= oldEnd; i++){
|
|
623
|
+
if (oldChildren[i]) this._removeNode(oldParent, oldChildren[i]);
|
|
624
|
+
}
|
|
683
625
|
}
|
|
684
|
-
newStartIdx++;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
if (oldStartIdx > oldEndIdx) {
|
|
688
|
-
const refNode = newChildren[newEndIdx + 1] ? oldChildren[oldStartIdx] : null;
|
|
689
|
-
for (let i = newStartIdx; i <= newEndIdx; i++) {
|
|
690
|
-
if (newChildren[i]) oldParent.insertBefore(newChildren[i].cloneNode(true), refNode);
|
|
691
|
-
}
|
|
692
|
-
} else if (newStartIdx > newEndIdx) {
|
|
693
|
-
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
|
|
694
|
-
if (oldChildren[i]) this._removeNode(oldParent, oldChildren[i]);
|
|
695
|
-
}
|
|
696
626
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
*
|
|
627
|
+
/**
|
|
628
|
+
* Patches a single node, updating its content and attributes to match the new node.
|
|
629
|
+
* Handles text nodes by updating nodeValue, and element nodes by updating attributes
|
|
630
|
+
* and recursively diffing children.
|
|
631
|
+
*
|
|
632
|
+
* Skips nodes that are managed by Eleva component instances to prevent interference
|
|
633
|
+
* with nested component state.
|
|
701
634
|
*
|
|
702
635
|
* @private
|
|
703
|
-
* @param {Node} oldNode - The original DOM node.
|
|
704
|
-
* @param {Node} newNode - The new DOM node.
|
|
636
|
+
* @param {Node} oldNode - The original DOM node to update.
|
|
637
|
+
* @param {Node} newNode - The new DOM node with desired state.
|
|
705
638
|
* @returns {void}
|
|
706
|
-
*/
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
oldNode.nodeValue = newNode.nodeValue;
|
|
639
|
+
*/ _patchNode(oldNode, newNode) {
|
|
640
|
+
// Skip nodes managed by Eleva component instances
|
|
641
|
+
if (oldNode._eleva_instance) return;
|
|
642
|
+
if (oldNode.nodeType === 3) {
|
|
643
|
+
if (oldNode.nodeValue !== newNode.nodeValue) {
|
|
644
|
+
oldNode.nodeValue = newNode.nodeValue;
|
|
645
|
+
}
|
|
646
|
+
} else if (oldNode.nodeType === 1) {
|
|
647
|
+
this._updateAttributes(oldNode, newNode);
|
|
648
|
+
this._diff(oldNode, newNode);
|
|
649
|
+
}
|
|
718
650
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
*
|
|
651
|
+
/**
|
|
652
|
+
* Removes a node from its parent, with special handling for Eleva-managed elements.
|
|
653
|
+
* Style elements with the `data-e-style` attribute are preserved to maintain
|
|
654
|
+
* component-scoped styles across re-renders.
|
|
723
655
|
*
|
|
724
656
|
* @private
|
|
725
|
-
* @param {HTMLElement} parent - The parent element containing the node
|
|
657
|
+
* @param {HTMLElement} parent - The parent element containing the node.
|
|
726
658
|
* @param {Node} node - The node to remove.
|
|
727
659
|
* @returns {void}
|
|
728
|
-
*/
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
/**
|
|
660
|
+
*/ _removeNode(parent, node) {
|
|
661
|
+
// Preserve Eleva-managed style elements
|
|
662
|
+
if (node.nodeName === "STYLE" && node.hasAttribute("data-e-style")) return;
|
|
663
|
+
parent.removeChild(node);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
735
666
|
* Updates the attributes of an element to match a new element's attributes.
|
|
667
|
+
* Adds new attributes, updates changed values, and removes attributes no longer present.
|
|
668
|
+
* Also syncs DOM properties that can diverge from attributes after user interaction.
|
|
669
|
+
*
|
|
670
|
+
* Event attributes (prefixed with `@`) are skipped as they are handled separately
|
|
671
|
+
* by Eleva's event binding system.
|
|
736
672
|
*
|
|
737
673
|
* @private
|
|
738
674
|
* @param {HTMLElement} oldEl - The original element to update.
|
|
739
|
-
* @param {HTMLElement} newEl - The new element
|
|
675
|
+
* @param {HTMLElement} newEl - The new element with target attributes.
|
|
740
676
|
* @returns {void}
|
|
741
|
-
*/
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
677
|
+
*/ _updateAttributes(oldEl, newEl) {
|
|
678
|
+
// Add/update attributes from new element
|
|
679
|
+
for (const attr of newEl.attributes){
|
|
680
|
+
// Skip event attributes (handled by Eleva's event system)
|
|
681
|
+
if (attr.name[0] === "@") continue;
|
|
682
|
+
if (oldEl.getAttribute(attr.name) !== attr.value) {
|
|
683
|
+
oldEl.setAttribute(attr.name, attr.value);
|
|
684
|
+
}
|
|
685
|
+
// Sync property if it exists and is writable (handles value, checked, selected, disabled, etc.)
|
|
686
|
+
if (attr.name in oldEl) {
|
|
687
|
+
try {
|
|
688
|
+
const newProp = typeof oldEl[attr.name] === "boolean" ? attr.value !== "false" // Attribute presence = true, unless explicitly "false"
|
|
689
|
+
: attr.value;
|
|
690
|
+
if (oldEl[attr.name] !== newProp) oldEl[attr.name] = newProp;
|
|
691
|
+
} catch {
|
|
692
|
+
continue; // Property is readonly
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// Remove attributes no longer present
|
|
697
|
+
for(let i = oldEl.attributes.length - 1; i >= 0; i--){
|
|
698
|
+
const name = oldEl.attributes[i].name;
|
|
699
|
+
if (!newEl.hasAttribute(name)) {
|
|
700
|
+
oldEl.removeAttribute(name);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// Sync properties that can diverge from attributes via user interaction
|
|
704
|
+
for (const prop of SYNC_PROPS){
|
|
705
|
+
if (prop in newEl && oldEl[prop] !== newEl[prop]) oldEl[prop] = newEl[prop];
|
|
706
|
+
}
|
|
769
707
|
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
*
|
|
708
|
+
/**
|
|
709
|
+
* Determines if two nodes are the same for reconciliation purposes.
|
|
710
|
+
* Two nodes are considered the same if:
|
|
711
|
+
* - Both have keys: keys match AND tag names match
|
|
712
|
+
* - Neither has keys: node types match AND node names match
|
|
713
|
+
* - One has key, other doesn't: not the same
|
|
714
|
+
*
|
|
715
|
+
* This ensures keyed elements are only reused when both key and tag match,
|
|
716
|
+
* preventing bugs like `<div key="a">` incorrectly matching `<span key="a">`.
|
|
774
717
|
*
|
|
775
718
|
* @private
|
|
776
719
|
* @param {Node} oldNode - The first node to compare.
|
|
777
720
|
* @param {Node} newNode - The second node to compare.
|
|
778
|
-
* @returns {boolean} True if the nodes are considered the same
|
|
779
|
-
*/
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
721
|
+
* @returns {boolean} True if the nodes are considered the same for reconciliation.
|
|
722
|
+
*/ _isSameNode(oldNode, newNode) {
|
|
723
|
+
if (!oldNode || !newNode) return false;
|
|
724
|
+
const oldKey = this._getNodeKey(oldNode);
|
|
725
|
+
const newKey = this._getNodeKey(newNode);
|
|
726
|
+
// If both have keys, compare by key AND tag name
|
|
727
|
+
if (oldKey && newKey) {
|
|
728
|
+
return oldKey === newKey && oldNode.nodeName === newNode.nodeName;
|
|
729
|
+
}
|
|
730
|
+
// Otherwise, compare by type and name
|
|
731
|
+
return !oldKey && !newKey && oldNode.nodeType === newNode.nodeType && oldNode.nodeName === newNode.nodeName;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Extracts the key attribute from a node if it exists.
|
|
735
|
+
* Only element nodes (nodeType === 1) can have key attributes.
|
|
791
736
|
*
|
|
792
737
|
* @private
|
|
793
|
-
* @param {
|
|
794
|
-
* @
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
*/
|
|
798
|
-
_createKeyMap(children, start, end) {
|
|
799
|
-
const map = new Map();
|
|
800
|
-
for (let i = start; i <= end; i++) {
|
|
801
|
-
const child = children[i];
|
|
802
|
-
const key = this._getNodeKey(child);
|
|
803
|
-
if (key) map.set(key, child);
|
|
738
|
+
* @param {Node|null|undefined} node - The node to extract the key from.
|
|
739
|
+
* @returns {string|null} The key attribute value, or null if not an element or no key.
|
|
740
|
+
*/ _getNodeKey(node) {
|
|
741
|
+
return node?.nodeType === 1 ? node.getAttribute("key") : null;
|
|
804
742
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Extracts the key attribute from a node if it exists.
|
|
743
|
+
/**
|
|
744
|
+
* Creates a key map for efficient O(1) lookup of keyed elements during diffing.
|
|
745
|
+
* The map is built lazily only when needed (when a mismatch occurs during diffing).
|
|
810
746
|
*
|
|
811
747
|
* @private
|
|
812
|
-
* @param {
|
|
813
|
-
* @
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
748
|
+
* @param {Array<ChildNode>} children - The array of child nodes to map.
|
|
749
|
+
* @param {number} start - The start index (inclusive) for mapping.
|
|
750
|
+
* @param {number} end - The end index (inclusive) for mapping.
|
|
751
|
+
* @returns {KeyMap} A Map of key strings to their corresponding DOM nodes.
|
|
752
|
+
*/ _createKeyMap(children, start, end) {
|
|
753
|
+
const map = new Map();
|
|
754
|
+
for(let i = start; i <= end; i++){
|
|
755
|
+
const key = this._getNodeKey(children[i]);
|
|
756
|
+
if (key) map.set(key, children[i]);
|
|
757
|
+
}
|
|
758
|
+
return map;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Creates a new Renderer instance.
|
|
762
|
+
*
|
|
763
|
+
* @public
|
|
764
|
+
*
|
|
765
|
+
* @example
|
|
766
|
+
* const renderer = new Renderer();
|
|
767
|
+
*/ constructor(){
|
|
768
|
+
/**
|
|
769
|
+
* Temporary container for parsing new HTML content.
|
|
770
|
+
* Reused across patch operations to minimize memory allocation.
|
|
771
|
+
* @private
|
|
772
|
+
* @type {HTMLDivElement}
|
|
773
|
+
*/ this._tempContainer = document.createElement("div");
|
|
774
|
+
}
|
|
818
775
|
}
|
|
819
776
|
|
|
820
777
|
// ============================================================================
|
|
821
778
|
// TYPE DEFINITIONS - TypeScript-friendly JSDoc types for IDE support
|
|
822
779
|
// ============================================================================
|
|
823
|
-
|
|
824
780
|
// -----------------------------------------------------------------------------
|
|
825
781
|
// Configuration Types
|
|
826
782
|
// -----------------------------------------------------------------------------
|
|
827
|
-
|
|
828
783
|
/**
|
|
829
784
|
* @typedef {Object} ElevaConfig
|
|
830
785
|
* @property {boolean} [debug=false]
|
|
@@ -833,12 +788,9 @@
|
|
|
833
788
|
* Prefix for component style scoping
|
|
834
789
|
* @property {boolean} [async=true]
|
|
835
790
|
* Enable async component setup
|
|
836
|
-
*/
|
|
837
|
-
|
|
838
|
-
// -----------------------------------------------------------------------------
|
|
791
|
+
*/ // -----------------------------------------------------------------------------
|
|
839
792
|
// Component Types
|
|
840
793
|
// -----------------------------------------------------------------------------
|
|
841
|
-
|
|
842
794
|
/**
|
|
843
795
|
* @typedef {Object} ComponentDefinition
|
|
844
796
|
* @property {SetupFunction} [setup]
|
|
@@ -849,40 +801,27 @@
|
|
|
849
801
|
* Optional function or string that provides component-scoped CSS styles
|
|
850
802
|
* @property {ChildrenMap} [children]
|
|
851
803
|
* Optional object defining nested child components
|
|
852
|
-
*/
|
|
853
|
-
|
|
854
|
-
/**
|
|
804
|
+
*/ /**
|
|
855
805
|
* @callback SetupFunction
|
|
856
806
|
* @param {ComponentContext} ctx - The component context with props, emitter, and signal factory
|
|
857
807
|
* @returns {SetupResult|Promise<SetupResult>} Reactive data and lifecycle hooks
|
|
858
|
-
*/
|
|
859
|
-
|
|
860
|
-
/**
|
|
808
|
+
*/ /**
|
|
861
809
|
* @typedef {Record<string, unknown> & LifecycleHooks} SetupResult
|
|
862
810
|
* Data returned from setup function, may include lifecycle hooks
|
|
863
|
-
*/
|
|
864
|
-
|
|
865
|
-
/**
|
|
811
|
+
*/ /**
|
|
866
812
|
* @callback TemplateFunction
|
|
867
813
|
* @param {ComponentContext} ctx - The component context
|
|
868
814
|
* @returns {string|Promise<string>} HTML template string
|
|
869
|
-
*/
|
|
870
|
-
|
|
871
|
-
/**
|
|
815
|
+
*/ /**
|
|
872
816
|
* @callback StyleFunction
|
|
873
817
|
* @param {ComponentContext} ctx - The component context
|
|
874
818
|
* @returns {string} CSS styles string
|
|
875
|
-
*/
|
|
876
|
-
|
|
877
|
-
/**
|
|
819
|
+
*/ /**
|
|
878
820
|
* @typedef {Record<string, ComponentDefinition|string>} ChildrenMap
|
|
879
821
|
* Map of CSS selectors to component definitions or registered component names
|
|
880
|
-
*/
|
|
881
|
-
|
|
882
|
-
// -----------------------------------------------------------------------------
|
|
822
|
+
*/ // -----------------------------------------------------------------------------
|
|
883
823
|
// Context Types
|
|
884
824
|
// -----------------------------------------------------------------------------
|
|
885
|
-
|
|
886
825
|
/**
|
|
887
826
|
* @typedef {Object} ComponentContext
|
|
888
827
|
* @property {ComponentProps} props
|
|
@@ -891,24 +830,17 @@
|
|
|
891
830
|
* Event emitter instance for component event handling
|
|
892
831
|
* @property {SignalFactory} signal
|
|
893
832
|
* Factory function to create reactive Signal instances
|
|
894
|
-
*/
|
|
895
|
-
|
|
896
|
-
/**
|
|
833
|
+
*/ /**
|
|
897
834
|
* @typedef {Record<string, unknown>} ComponentProps
|
|
898
835
|
* Properties passed to a component during mounting
|
|
899
|
-
*/
|
|
900
|
-
|
|
901
|
-
/**
|
|
836
|
+
*/ /**
|
|
902
837
|
* @callback SignalFactory
|
|
903
838
|
* @template T
|
|
904
839
|
* @param {T} initialValue - The initial value for the signal
|
|
905
840
|
* @returns {Signal<T>} A new Signal instance
|
|
906
|
-
*/
|
|
907
|
-
|
|
908
|
-
// -----------------------------------------------------------------------------
|
|
841
|
+
*/ // -----------------------------------------------------------------------------
|
|
909
842
|
// Lifecycle Hook Types
|
|
910
843
|
// -----------------------------------------------------------------------------
|
|
911
|
-
|
|
912
844
|
/**
|
|
913
845
|
* @typedef {Object} LifecycleHooks
|
|
914
846
|
* @property {LifecycleHook} [onBeforeMount]
|
|
@@ -921,29 +853,21 @@
|
|
|
921
853
|
* Hook called after component update
|
|
922
854
|
* @property {UnmountHook} [onUnmount]
|
|
923
855
|
* Hook called during component unmounting
|
|
924
|
-
*/
|
|
925
|
-
|
|
926
|
-
/**
|
|
856
|
+
*/ /**
|
|
927
857
|
* @callback LifecycleHook
|
|
928
858
|
* @param {LifecycleHookContext} ctx - Context with container and component data
|
|
929
859
|
* @returns {void|Promise<void>}
|
|
930
|
-
*/
|
|
931
|
-
|
|
932
|
-
/**
|
|
860
|
+
*/ /**
|
|
933
861
|
* @callback UnmountHook
|
|
934
862
|
* @param {UnmountHookContext} ctx - Context with cleanup resources
|
|
935
863
|
* @returns {void|Promise<void>}
|
|
936
|
-
*/
|
|
937
|
-
|
|
938
|
-
/**
|
|
864
|
+
*/ /**
|
|
939
865
|
* @typedef {Object} LifecycleHookContext
|
|
940
866
|
* @property {HTMLElement} container
|
|
941
867
|
* The DOM element where the component is mounted
|
|
942
868
|
* @property {ComponentContext & SetupResult} context
|
|
943
869
|
* The component's reactive state and context data
|
|
944
|
-
*/
|
|
945
|
-
|
|
946
|
-
/**
|
|
870
|
+
*/ /**
|
|
947
871
|
* @typedef {Object} UnmountHookContext
|
|
948
872
|
* @property {HTMLElement} container
|
|
949
873
|
* The DOM element where the component is mounted
|
|
@@ -951,9 +875,7 @@
|
|
|
951
875
|
* The component's reactive state and context data
|
|
952
876
|
* @property {CleanupResources} cleanup
|
|
953
877
|
* Object containing cleanup functions and instances
|
|
954
|
-
*/
|
|
955
|
-
|
|
956
|
-
/**
|
|
878
|
+
*/ /**
|
|
957
879
|
* @typedef {Object} CleanupResources
|
|
958
880
|
* @property {Array<UnsubscribeFunction>} watchers
|
|
959
881
|
* Signal watcher cleanup functions
|
|
@@ -961,12 +883,9 @@
|
|
|
961
883
|
* Event listener cleanup functions
|
|
962
884
|
* @property {Array<MountResult>} children
|
|
963
885
|
* Child component instances
|
|
964
|
-
*/
|
|
965
|
-
|
|
966
|
-
// -----------------------------------------------------------------------------
|
|
886
|
+
*/ // -----------------------------------------------------------------------------
|
|
967
887
|
// Mount Result Types
|
|
968
888
|
// -----------------------------------------------------------------------------
|
|
969
|
-
|
|
970
889
|
/**
|
|
971
890
|
* @typedef {Object} MountResult
|
|
972
891
|
* @property {HTMLElement} container
|
|
@@ -975,22 +894,15 @@
|
|
|
975
894
|
* The component's reactive state and context data
|
|
976
895
|
* @property {UnmountFunction} unmount
|
|
977
896
|
* Function to clean up and unmount the component
|
|
978
|
-
*/
|
|
979
|
-
|
|
980
|
-
/**
|
|
897
|
+
*/ /**
|
|
981
898
|
* @callback UnmountFunction
|
|
982
899
|
* @returns {Promise<void>}
|
|
983
|
-
*/
|
|
984
|
-
|
|
985
|
-
/**
|
|
900
|
+
*/ /**
|
|
986
901
|
* @callback UnsubscribeFunction
|
|
987
902
|
* @returns {void|boolean}
|
|
988
|
-
*/
|
|
989
|
-
|
|
990
|
-
// -----------------------------------------------------------------------------
|
|
903
|
+
*/ // -----------------------------------------------------------------------------
|
|
991
904
|
// Plugin Types
|
|
992
905
|
// -----------------------------------------------------------------------------
|
|
993
|
-
|
|
994
906
|
/**
|
|
995
907
|
* @typedef {Object} ElevaPlugin
|
|
996
908
|
* @property {PluginInstallFunction} install
|
|
@@ -999,42 +911,29 @@
|
|
|
999
911
|
* Unique identifier name for the plugin
|
|
1000
912
|
* @property {PluginUninstallFunction} [uninstall]
|
|
1001
913
|
* Optional function to uninstall the plugin
|
|
1002
|
-
*/
|
|
1003
|
-
|
|
1004
|
-
/**
|
|
914
|
+
*/ /**
|
|
1005
915
|
* @callback PluginInstallFunction
|
|
1006
916
|
* @param {Eleva} eleva - The Eleva instance
|
|
1007
917
|
* @param {PluginOptions} options - Plugin configuration options
|
|
1008
918
|
* @returns {void|Eleva|unknown} Optionally returns the Eleva instance or plugin result
|
|
1009
|
-
*/
|
|
1010
|
-
|
|
1011
|
-
/**
|
|
919
|
+
*/ /**
|
|
1012
920
|
* @callback PluginUninstallFunction
|
|
1013
921
|
* @param {Eleva} eleva - The Eleva instance
|
|
1014
922
|
* @returns {void}
|
|
1015
|
-
*/
|
|
1016
|
-
|
|
1017
|
-
/**
|
|
923
|
+
*/ /**
|
|
1018
924
|
* @typedef {Record<string, unknown>} PluginOptions
|
|
1019
925
|
* Configuration options passed to a plugin during installation
|
|
1020
|
-
*/
|
|
1021
|
-
|
|
1022
|
-
// -----------------------------------------------------------------------------
|
|
926
|
+
*/ // -----------------------------------------------------------------------------
|
|
1023
927
|
// Event Types
|
|
1024
928
|
// -----------------------------------------------------------------------------
|
|
1025
|
-
|
|
1026
929
|
/**
|
|
1027
930
|
* @callback EventHandler
|
|
1028
931
|
* @param {Event} event - The DOM event object
|
|
1029
932
|
* @returns {void}
|
|
1030
|
-
*/
|
|
1031
|
-
|
|
1032
|
-
/**
|
|
933
|
+
*/ /**
|
|
1033
934
|
* @typedef {'click'|'submit'|'input'|'change'|'focus'|'blur'|'keydown'|'keyup'|'keypress'|'mouseenter'|'mouseleave'|'mouseover'|'mouseout'|'mousedown'|'mouseup'|'touchstart'|'touchend'|'touchmove'|'scroll'|'resize'|'load'|'error'|string} DOMEventName
|
|
1034
935
|
* Common DOM event names (prefixed with @ in templates)
|
|
1035
|
-
*/
|
|
1036
|
-
|
|
1037
|
-
/**
|
|
936
|
+
*/ /**
|
|
1038
937
|
* @class 🧩 Eleva
|
|
1039
938
|
* @classdesc A modern, signal-based component runtime framework that provides lifecycle hooks,
|
|
1040
939
|
* scoped styles, and plugin support. Eleva manages component registration, plugin integration,
|
|
@@ -1061,50 +960,8 @@
|
|
|
1061
960
|
* },
|
|
1062
961
|
* template: `<div>Lifecycle Demo</div>`
|
|
1063
962
|
* });
|
|
1064
|
-
*/
|
|
1065
|
-
|
|
1066
|
-
/**
|
|
1067
|
-
* Creates a new Eleva instance with the specified name and configuration.
|
|
1068
|
-
*
|
|
1069
|
-
* @public
|
|
1070
|
-
* @param {string} name - The unique identifier name for this Eleva instance.
|
|
1071
|
-
* @param {Record<string, unknown>} [config={}] - Optional configuration object for the instance.
|
|
1072
|
-
* May include framework-wide settings and default behaviors.
|
|
1073
|
-
* @throws {Error} If the name is not provided or is not a string.
|
|
1074
|
-
* @returns {Eleva} A new Eleva instance.
|
|
1075
|
-
*
|
|
1076
|
-
* @example
|
|
1077
|
-
* const app = new Eleva("myApp");
|
|
1078
|
-
* app.component("myComponent", {
|
|
1079
|
-
* setup: (ctx) => ({ count: ctx.signal(0) }),
|
|
1080
|
-
* template: (ctx) => `<div>Hello ${ctx.props.name}!</div>`
|
|
1081
|
-
* });
|
|
1082
|
-
* app.mount(document.getElementById("app"), "myComponent", { name: "World" });
|
|
1083
|
-
*
|
|
1084
|
-
*/
|
|
1085
|
-
constructor(name, config = {}) {
|
|
1086
|
-
/** @public {string} The unique identifier name for this Eleva instance */
|
|
1087
|
-
this.name = name;
|
|
1088
|
-
/** @public {Object<string, unknown>} Optional configuration object for the Eleva instance */
|
|
1089
|
-
this.config = config;
|
|
1090
|
-
/** @public {Emitter} Instance of the event emitter for handling component events */
|
|
1091
|
-
this.emitter = new Emitter();
|
|
1092
|
-
/** @public {typeof Signal} Static reference to the Signal class for creating reactive state */
|
|
1093
|
-
this.signal = Signal;
|
|
1094
|
-
/** @public {typeof TemplateEngine} Static reference to the TemplateEngine class for template parsing */
|
|
1095
|
-
this.templateEngine = TemplateEngine;
|
|
1096
|
-
/** @public {Renderer} Instance of the renderer for handling DOM updates and patching */
|
|
1097
|
-
this.renderer = new Renderer();
|
|
1098
|
-
|
|
1099
|
-
/** @private {Map<string, ComponentDefinition>} Registry of all component definitions by name */
|
|
1100
|
-
this._components = new Map();
|
|
1101
|
-
/** @private {Map<string, ElevaPlugin>} Collection of installed plugin instances by name */
|
|
1102
|
-
this._plugins = new Map();
|
|
1103
|
-
/** @private {number} Counter for generating unique component IDs */
|
|
1104
|
-
this._componentCounter = 0;
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
/**
|
|
963
|
+
*/ class Eleva {
|
|
964
|
+
/**
|
|
1108
965
|
* Integrates a plugin with the Eleva framework.
|
|
1109
966
|
* The plugin's install function will be called with the Eleva instance and provided options.
|
|
1110
967
|
* After installation, the plugin will be available for use by components.
|
|
@@ -1116,6 +973,7 @@
|
|
|
1116
973
|
* @param {ElevaPlugin} plugin - The plugin object which must have an `install` function.
|
|
1117
974
|
* @param {Object<string, unknown>} [options={}] - Optional configuration options for the plugin.
|
|
1118
975
|
* @returns {Eleva} The Eleva instance (for method chaining).
|
|
976
|
+
* @throws {Error} If plugin does not have an install function.
|
|
1119
977
|
* @example
|
|
1120
978
|
* app.use(myPlugin, { option1: "value1" });
|
|
1121
979
|
*
|
|
@@ -1126,14 +984,15 @@
|
|
|
1126
984
|
* // Uninstall in reverse order:
|
|
1127
985
|
* PluginB.uninstall(app);
|
|
1128
986
|
* PluginA.uninstall(app);
|
|
1129
|
-
*/
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
987
|
+
*/ use(plugin, options = {}) {
|
|
988
|
+
if (!plugin?.install || typeof plugin.install !== "function") {
|
|
989
|
+
throw new Error("Eleva: plugin must have an install function");
|
|
990
|
+
}
|
|
991
|
+
this._plugins.set(plugin.name, plugin);
|
|
992
|
+
const result = plugin.install(this, options);
|
|
993
|
+
return result !== undefined ? result : this;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
1137
996
|
* Registers a new component with the Eleva instance.
|
|
1138
997
|
* The component will be available for mounting using its registered name.
|
|
1139
998
|
*
|
|
@@ -1141,20 +1000,23 @@
|
|
|
1141
1000
|
* @param {string} name - The unique name of the component to register.
|
|
1142
1001
|
* @param {ComponentDefinition} definition - The component definition including setup, template, style, and children.
|
|
1143
1002
|
* @returns {Eleva} The Eleva instance (for method chaining).
|
|
1144
|
-
* @throws {Error} If
|
|
1003
|
+
* @throws {Error} If name is not a non-empty string or definition has no template.
|
|
1145
1004
|
* @example
|
|
1146
1005
|
* app.component("myButton", {
|
|
1147
1006
|
* template: (ctx) => `<button>${ctx.props.text}</button>`,
|
|
1148
1007
|
* style: `button { color: blue; }`
|
|
1149
1008
|
* });
|
|
1150
|
-
*/
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1009
|
+
*/ component(name, definition) {
|
|
1010
|
+
if (!name || typeof name !== "string") {
|
|
1011
|
+
throw new Error("Eleva: component name must be a non-empty string");
|
|
1012
|
+
}
|
|
1013
|
+
if (!definition?.template) {
|
|
1014
|
+
throw new Error(`Eleva: component "${name}" must have a template`);
|
|
1015
|
+
}
|
|
1016
|
+
/** @type {Map<string, ComponentDefinition>} */ this._components.set(name, definition);
|
|
1017
|
+
return this;
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1158
1020
|
* Mounts a registered component to a DOM element.
|
|
1159
1021
|
* This will initialize the component, set up its reactive state, and render it to the DOM.
|
|
1160
1022
|
*
|
|
@@ -1167,46 +1029,32 @@
|
|
|
1167
1029
|
* - container: The mounted component's container element
|
|
1168
1030
|
* - data: The component's reactive state and context
|
|
1169
1031
|
* - unmount: Function to clean up and unmount the component
|
|
1170
|
-
* @throws {Error} If
|
|
1032
|
+
* @throws {Error} If container is not a DOM element or component is not registered.
|
|
1171
1033
|
* @example
|
|
1172
1034
|
* const instance = await app.mount(document.getElementById("app"), "myComponent", { text: "Click me" });
|
|
1173
1035
|
* // Later...
|
|
1174
1036
|
* instance.unmount();
|
|
1175
|
-
*/
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
/** @type {string} */
|
|
1185
|
-
const compId = `c${++this._componentCounter}`;
|
|
1186
|
-
|
|
1187
|
-
/**
|
|
1037
|
+
*/ async mount(container, compName, props = {}) {
|
|
1038
|
+
if (!container?.nodeType) {
|
|
1039
|
+
throw new Error("Eleva: container must be a DOM element");
|
|
1040
|
+
}
|
|
1041
|
+
if (container._eleva_instance) return container._eleva_instance;
|
|
1042
|
+
/** @type {ComponentDefinition} */ const definition = typeof compName === "string" ? this._components.get(compName) : compName;
|
|
1043
|
+
if (!definition) throw new Error(`Component "${compName}" not registered.`);
|
|
1044
|
+
/** @type {string} */ const compId = `c${++this._componentCounter}`;
|
|
1045
|
+
/**
|
|
1188
1046
|
* Destructure the component definition to access core functionality.
|
|
1189
1047
|
* - setup: Optional function for component initialization and state management
|
|
1190
1048
|
* - template: Required function or string that returns the component's HTML structure
|
|
1191
1049
|
* - style: Optional function or string for component-scoped CSS styles
|
|
1192
1050
|
* - children: Optional object defining nested child components
|
|
1193
|
-
*/
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
/** @type {ComponentContext} */
|
|
1202
|
-
const context = {
|
|
1203
|
-
props,
|
|
1204
|
-
emitter: this.emitter,
|
|
1205
|
-
/** @type {(v: unknown) => Signal<unknown>} */
|
|
1206
|
-
signal: v => new this.signal(v)
|
|
1207
|
-
};
|
|
1208
|
-
|
|
1209
|
-
/**
|
|
1051
|
+
*/ const { setup, template, style, children } = definition;
|
|
1052
|
+
/** @type {ComponentContext} */ const context = {
|
|
1053
|
+
props,
|
|
1054
|
+
emitter: this.emitter,
|
|
1055
|
+
/** @type {(v: unknown) => Signal<unknown>} */ signal: (v)=>new this.signal(v)
|
|
1056
|
+
};
|
|
1057
|
+
/**
|
|
1210
1058
|
* Processes the mounting of the component.
|
|
1211
1059
|
* This function handles:
|
|
1212
1060
|
* 1. Merging setup data with the component context
|
|
@@ -1219,132 +1067,114 @@
|
|
|
1219
1067
|
* - container: The mounted component's container element
|
|
1220
1068
|
* - data: The component's reactive state and context
|
|
1221
1069
|
* - unmount: Function to clean up and unmount the component
|
|
1222
|
-
*/
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
/** @private {boolean} Flag to prevent multiple queued renders */
|
|
1243
|
-
let renderScheduled = false;
|
|
1244
|
-
|
|
1245
|
-
/**
|
|
1246
|
-
* Schedules a batched render on the next microtask.
|
|
1247
|
-
* Multiple signal changes within the same synchronous block are collapsed into one render.
|
|
1070
|
+
*/ const processMount = async (data)=>{
|
|
1071
|
+
/** @type {ComponentContext} */ const mergedContext = {
|
|
1072
|
+
...context,
|
|
1073
|
+
...data
|
|
1074
|
+
};
|
|
1075
|
+
/** @type {Array<() => void>} */ const watchers = [];
|
|
1076
|
+
/** @type {Array<MountResult>} */ const childInstances = [];
|
|
1077
|
+
/** @type {Array<() => void>} */ const listeners = [];
|
|
1078
|
+
/** @private {boolean} Local mounted state for this component instance */ let isMounted = false;
|
|
1079
|
+
// ========================================================================
|
|
1080
|
+
// Render Batching
|
|
1081
|
+
// ========================================================================
|
|
1082
|
+
/** @private {boolean} Flag to prevent concurrent renders */ let renderScheduled = false;
|
|
1083
|
+
/**
|
|
1084
|
+
* Schedules a render using microtask batching.
|
|
1085
|
+
* Since signals now notify watchers synchronously, multiple signal
|
|
1086
|
+
* changes in the same synchronous block will each call this function,
|
|
1087
|
+
* but only one render will be scheduled via queueMicrotask.
|
|
1088
|
+
* This separates concerns: signals handle state, components handle scheduling.
|
|
1248
1089
|
* @private
|
|
1249
|
-
*/
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
/**
|
|
1090
|
+
*/ const scheduleRender = ()=>{
|
|
1091
|
+
if (renderScheduled) return;
|
|
1092
|
+
renderScheduled = true;
|
|
1093
|
+
queueMicrotask(async ()=>{
|
|
1094
|
+
renderScheduled = false;
|
|
1095
|
+
await render();
|
|
1096
|
+
});
|
|
1097
|
+
};
|
|
1098
|
+
/**
|
|
1260
1099
|
* Renders the component by:
|
|
1261
1100
|
* 1. Executing lifecycle hooks
|
|
1262
1101
|
* 2. Processing the template
|
|
1263
1102
|
* 3. Updating the DOM
|
|
1264
1103
|
* 4. Processing events, injecting styles, and mounting child components.
|
|
1265
|
-
*/
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
}
|
|
1300
|
-
};
|
|
1301
|
-
|
|
1302
|
-
/**
|
|
1104
|
+
*/ const render = async ()=>{
|
|
1105
|
+
const templateResult = typeof template === "function" ? await template(mergedContext) : template;
|
|
1106
|
+
const html = this.templateEngine.parse(templateResult, mergedContext);
|
|
1107
|
+
// Execute before hooks
|
|
1108
|
+
if (!isMounted) {
|
|
1109
|
+
await mergedContext.onBeforeMount?.({
|
|
1110
|
+
container,
|
|
1111
|
+
context: mergedContext
|
|
1112
|
+
});
|
|
1113
|
+
} else {
|
|
1114
|
+
await mergedContext.onBeforeUpdate?.({
|
|
1115
|
+
container,
|
|
1116
|
+
context: mergedContext
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
this.renderer.patchDOM(container, html);
|
|
1120
|
+
this._processEvents(container, mergedContext, listeners);
|
|
1121
|
+
if (style) this._injectStyles(container, compId, style, mergedContext);
|
|
1122
|
+
if (children) await this._mountComponents(container, children, childInstances);
|
|
1123
|
+
// Execute after hooks
|
|
1124
|
+
if (!isMounted) {
|
|
1125
|
+
await mergedContext.onMount?.({
|
|
1126
|
+
container,
|
|
1127
|
+
context: mergedContext
|
|
1128
|
+
});
|
|
1129
|
+
isMounted = true;
|
|
1130
|
+
} else {
|
|
1131
|
+
await mergedContext.onUpdate?.({
|
|
1132
|
+
container,
|
|
1133
|
+
context: mergedContext
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
/**
|
|
1303
1138
|
* Sets up reactive watchers for all Signal instances in the component's data.
|
|
1304
1139
|
* When a Signal's value changes, a batched render is scheduled.
|
|
1305
1140
|
* Multiple changes within the same frame are collapsed into one render.
|
|
1306
1141
|
* Stores unsubscribe functions to clean up watchers when component unmounts.
|
|
1307
|
-
*/
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
/**
|
|
1142
|
+
*/ for (const val of Object.values(data)){
|
|
1143
|
+
if (val instanceof Signal) watchers.push(val.watch(scheduleRender));
|
|
1144
|
+
}
|
|
1145
|
+
await render();
|
|
1146
|
+
const instance = {
|
|
1147
|
+
container,
|
|
1148
|
+
data: mergedContext,
|
|
1149
|
+
/**
|
|
1316
1150
|
* Unmounts the component, cleaning up watchers and listeners, child components, and clearing the container.
|
|
1317
1151
|
*
|
|
1318
1152
|
* @returns {void}
|
|
1319
|
-
*/
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
return await processMount(setupResult);
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
/**
|
|
1153
|
+
*/ unmount: async ()=>{
|
|
1154
|
+
/** @type {UnmountHookContext} */ await mergedContext.onUnmount?.({
|
|
1155
|
+
container,
|
|
1156
|
+
context: mergedContext,
|
|
1157
|
+
cleanup: {
|
|
1158
|
+
watchers: watchers,
|
|
1159
|
+
listeners: listeners,
|
|
1160
|
+
children: childInstances
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
for (const fn of watchers)fn();
|
|
1164
|
+
for (const fn of listeners)fn();
|
|
1165
|
+
for (const child of childInstances)await child.unmount();
|
|
1166
|
+
container.innerHTML = "";
|
|
1167
|
+
delete container._eleva_instance;
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
container._eleva_instance = instance;
|
|
1171
|
+
return instance;
|
|
1172
|
+
};
|
|
1173
|
+
// Handle asynchronous setup.
|
|
1174
|
+
const setupResult = typeof setup === "function" ? await setup(context) : {};
|
|
1175
|
+
return await processMount(setupResult);
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1348
1178
|
* Processes DOM elements for event binding based on attributes starting with "@".
|
|
1349
1179
|
* This method handles the event delegation system and ensures proper cleanup of event listeners.
|
|
1350
1180
|
*
|
|
@@ -1353,34 +1183,25 @@
|
|
|
1353
1183
|
* @param {ComponentContext} context - The current component context containing event handler definitions.
|
|
1354
1184
|
* @param {Array<() => void>} listeners - Array to collect cleanup functions for each event listener.
|
|
1355
1185
|
* @returns {void}
|
|
1356
|
-
*/
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
/** @type {(event: Event) => void} */
|
|
1373
|
-
const handler = context[handlerName] || this.templateEngine.evaluate(handlerName, context);
|
|
1374
|
-
if (typeof handler === "function") {
|
|
1375
|
-
el.addEventListener(event, handler);
|
|
1376
|
-
el.removeAttribute(attr.name);
|
|
1377
|
-
listeners.push(() => el.removeEventListener(event, handler));
|
|
1186
|
+
*/ _processEvents(container, context, listeners) {
|
|
1187
|
+
/** @type {NodeListOf<Element>} */ const elements = container.querySelectorAll("*");
|
|
1188
|
+
for (const el of elements){
|
|
1189
|
+
/** @type {NamedNodeMap} */ const attrs = el.attributes;
|
|
1190
|
+
for(let i = 0; i < attrs.length; i++){
|
|
1191
|
+
/** @type {Attr} */ const attr = attrs[i];
|
|
1192
|
+
if (!attr.name.startsWith("@")) continue;
|
|
1193
|
+
/** @type {keyof HTMLElementEventMap} */ const event = attr.name.slice(1);
|
|
1194
|
+
/** @type {string} */ const handlerName = attr.value;
|
|
1195
|
+
/** @type {(event: Event) => void} */ const handler = context[handlerName] || this.templateEngine.evaluate(handlerName, context);
|
|
1196
|
+
if (typeof handler === "function") {
|
|
1197
|
+
el.addEventListener(event, handler);
|
|
1198
|
+
el.removeAttribute(attr.name);
|
|
1199
|
+
listeners.push(()=>el.removeEventListener(event, handler));
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1378
1202
|
}
|
|
1379
|
-
}
|
|
1380
1203
|
}
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
/**
|
|
1204
|
+
/**
|
|
1384
1205
|
* Injects scoped styles into the component's container.
|
|
1385
1206
|
* The styles are automatically prefixed to prevent style leakage to other components.
|
|
1386
1207
|
*
|
|
@@ -1390,23 +1211,18 @@
|
|
|
1390
1211
|
* @param {(function(ComponentContext): string)|string} styleDef - The component's style definition (function or string).
|
|
1391
1212
|
* @param {ComponentContext} context - The current component context for style interpolation.
|
|
1392
1213
|
* @returns {void}
|
|
1393
|
-
*/
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
styleEl.setAttribute("data-e-style", compId);
|
|
1404
|
-
container.appendChild(styleEl);
|
|
1214
|
+
*/ _injectStyles(container, compId, styleDef, context) {
|
|
1215
|
+
/** @type {string} */ const newStyle = typeof styleDef === "function" ? this.templateEngine.parse(styleDef(context), context) : styleDef;
|
|
1216
|
+
/** @type {HTMLStyleElement|null} */ let styleEl = container.querySelector(`style[data-e-style="${compId}"]`);
|
|
1217
|
+
if (styleEl && styleEl.textContent === newStyle) return;
|
|
1218
|
+
if (!styleEl) {
|
|
1219
|
+
styleEl = document.createElement("style");
|
|
1220
|
+
styleEl.setAttribute("data-e-style", compId);
|
|
1221
|
+
container.appendChild(styleEl);
|
|
1222
|
+
}
|
|
1223
|
+
styleEl.textContent = newStyle;
|
|
1405
1224
|
}
|
|
1406
|
-
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
/**
|
|
1225
|
+
/**
|
|
1410
1226
|
* Extracts props from an element's attributes that start with the specified prefix.
|
|
1411
1227
|
* This method is used to collect component properties from DOM elements.
|
|
1412
1228
|
*
|
|
@@ -1417,23 +1233,21 @@
|
|
|
1417
1233
|
* // For an element with attributes:
|
|
1418
1234
|
* // <div :name="John" :age="25">
|
|
1419
1235
|
* // Returns: { name: "John", age: "25" }
|
|
1420
|
-
*/
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1236
|
+
*/ _extractProps(element) {
|
|
1237
|
+
if (!element.attributes) return {};
|
|
1238
|
+
const props = {};
|
|
1239
|
+
const attrs = element.attributes;
|
|
1240
|
+
for(let i = attrs.length - 1; i >= 0; i--){
|
|
1241
|
+
const attr = attrs[i];
|
|
1242
|
+
if (attr.name.startsWith(":")) {
|
|
1243
|
+
const propName = attr.name.slice(1);
|
|
1244
|
+
props[propName] = attr.value;
|
|
1245
|
+
element.removeAttribute(attr.name);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return props;
|
|
1432
1249
|
}
|
|
1433
|
-
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
/**
|
|
1250
|
+
/**
|
|
1437
1251
|
* Mounts all components within the parent component's container.
|
|
1438
1252
|
* This method handles mounting of explicitly defined children components.
|
|
1439
1253
|
*
|
|
@@ -1453,22 +1267,51 @@
|
|
|
1453
1267
|
* 'UserProfile': UserProfileComponent,
|
|
1454
1268
|
* '#settings-panel': "settings-panel"
|
|
1455
1269
|
* };
|
|
1456
|
-
*/
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1270
|
+
*/ async _mountComponents(container, children, childInstances) {
|
|
1271
|
+
for (const [selector, component] of Object.entries(children)){
|
|
1272
|
+
if (!selector) continue;
|
|
1273
|
+
for (const el of container.querySelectorAll(selector)){
|
|
1274
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
1275
|
+
/** @type {Record<string, string>} */ const props = this._extractProps(el);
|
|
1276
|
+
/** @type {MountResult} */ const instance = await this.mount(el, component, props);
|
|
1277
|
+
if (instance && !childInstances.includes(instance)) {
|
|
1278
|
+
childInstances.push(instance);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Creates a new Eleva instance with the specified name and configuration.
|
|
1285
|
+
*
|
|
1286
|
+
* @public
|
|
1287
|
+
* @param {string} name - The unique identifier name for this Eleva instance.
|
|
1288
|
+
* @param {Record<string, unknown>} [config={}] - Optional configuration object for the instance.
|
|
1289
|
+
* May include framework-wide settings and default behaviors.
|
|
1290
|
+
* @throws {Error} If the name is not provided or is not a string.
|
|
1291
|
+
* @returns {Eleva} A new Eleva instance.
|
|
1292
|
+
*
|
|
1293
|
+
* @example
|
|
1294
|
+
* const app = new Eleva("myApp");
|
|
1295
|
+
* app.component("myComponent", {
|
|
1296
|
+
* setup: (ctx) => ({ count: ctx.signal(0) }),
|
|
1297
|
+
* template: (ctx) => `<div>Hello ${ctx.props.name}!</div>`
|
|
1298
|
+
* });
|
|
1299
|
+
* app.mount(document.getElementById("app"), "myComponent", { name: "World" });
|
|
1300
|
+
*
|
|
1301
|
+
*/ constructor(name, config = {}){
|
|
1302
|
+
if (!name || typeof name !== "string") {
|
|
1303
|
+
throw new Error("Eleva: name must be a non-empty string");
|
|
1468
1304
|
}
|
|
1469
|
-
|
|
1305
|
+
/** @public {string} The unique identifier name for this Eleva instance */ this.name = name;
|
|
1306
|
+
/** @public {Object<string, unknown>} Optional configuration object for the Eleva instance */ this.config = config;
|
|
1307
|
+
/** @public {Emitter} Instance of the event emitter for handling component events */ this.emitter = new Emitter();
|
|
1308
|
+
/** @public {typeof Signal} Static reference to the Signal class for creating reactive state */ this.signal = Signal;
|
|
1309
|
+
/** @public {typeof TemplateEngine} Static reference to the TemplateEngine class for template parsing */ this.templateEngine = TemplateEngine;
|
|
1310
|
+
/** @public {Renderer} Instance of the renderer for handling DOM updates and patching */ this.renderer = new Renderer();
|
|
1311
|
+
/** @private {Map<string, ComponentDefinition>} Registry of all component definitions by name */ this._components = new Map();
|
|
1312
|
+
/** @private {Map<string, ElevaPlugin>} Collection of installed plugin instances by name */ this._plugins = new Map();
|
|
1313
|
+
/** @private {number} Counter for generating unique component IDs */ this._componentCounter = 0;
|
|
1470
1314
|
}
|
|
1471
|
-
}
|
|
1472
1315
|
}
|
|
1473
1316
|
|
|
1474
1317
|
return Eleva;
|